Refactor persistence to combine document handling and workspace layout (#4031)

* Unify editor state persistence

* Review

* Fix

* Remove redundant DocumentDetails

* LoadDocumentContent indirection
This commit is contained in:
Timon
2026-04-19 11:31:21 +02:00
committed by GitHub
parent 2a2a60883d
commit 6c5e3c97f8
36 changed files with 562 additions and 699 deletions

View File

@@ -4,7 +4,7 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import Panel from "/src/components/window/Panel.svelte";
import type { PortfolioStore } from "/src/stores/portfolio";
import type { EditorWrapper, OpenDocument, PanelGroupState, PanelLayoutSubdivision } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision } from "/wrapper/pkg/graphite_wasm_wrapper";
const MIN_PANEL_SIZE = 100;
const DOUBLE_CLICK_MILLISECONDS = 500;
@@ -31,9 +31,9 @@
$: if (subdivision) sizeOverrides = {};
// Reactive array of resolved sizes (merging backend defaults with local overrides)
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
const name = doc.details.name;
const unsaved = !doc.details.is_saved;
$: documentTabLabels = $portfolio.documents.map((doc: DocumentInfo) => {
const name = doc.name;
const unsaved = !doc.is_saved;
if (!editor.inDevelopmentMode()) return { name, unsaved };
const tooltipDescription = `Document ID: ${doc.id}`;

View File

@@ -3,12 +3,11 @@ import type { SubscriptionsRouter } from "/src/subscriptions-router";
import {
saveEditorPreferences,
loadEditorPreferences,
saveWorkspaceLayout,
loadWorkspaceLayout,
storeDocument,
removeDocument,
loadDocuments,
saveActiveDocument,
writePersistedState,
readPersistedState,
writePersistedDocument,
readPersistedDocument,
deletePersistedDocument,
} from "/src/utility-functions/persistence";
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
@@ -31,33 +30,29 @@ export function createPersistenceManager(subscriptions: SubscriptionsRouter, edi
await loadEditorPreferences(editor);
});
subscriptions.subscribeFrontendMessage("TriggerSaveWorkspaceLayout", async (data) => {
await saveWorkspaceLayout(data.workspaceLayout);
subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteState", async (data) => {
await writePersistedState(data.state);
});
subscriptions.subscribeFrontendMessage("TriggerLoadWorkspaceLayout", async () => {
await loadWorkspaceLayout(editor);
subscriptions.subscribeFrontendMessage("TriggerPersistenceReadState", async () => {
await readPersistedState(editor);
});
subscriptions.subscribeFrontendMessage("TriggerPersistenceWriteDocument", async (data) => {
await storeDocument(data, portfolio);
await writePersistedDocument(data);
});
subscriptions.subscribeFrontendMessage("TriggerPersistenceRemoveDocument", async (data) => {
await removeDocument(String(data.documentId), portfolio);
subscriptions.subscribeFrontendMessage("TriggerPersistenceReadDocument", async (data) => {
await readPersistedDocument(data.documentId, editor);
});
subscriptions.subscribeFrontendMessage("TriggerLoadAutoSaveDocuments", async () => {
await loadDocuments(editor);
subscriptions.subscribeFrontendMessage("TriggerPersistenceDeleteDocument", async (data) => {
await deletePersistedDocument(String(data.documentId));
});
subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
// TODO: Could be used to load documents from URL params or similar on launch
});
subscriptions.subscribeFrontendMessage("TriggerSaveActiveDocument", async (data) => {
await saveActiveDocument(data.documentId);
});
}
export function destroyPersistenceManager() {
@@ -66,13 +61,12 @@ export function destroyPersistenceManager() {
subscriptions.unsubscribeFrontendMessage("TriggerSavePreferences");
subscriptions.unsubscribeFrontendMessage("TriggerLoadPreferences");
subscriptions.unsubscribeFrontendMessage("TriggerSaveWorkspaceLayout");
subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteState");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceReadState");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
subscriptions.unsubscribeFrontendMessage("TriggerLoadAutoSaveDocuments");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceReadDocument");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceDeleteDocument");
subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments");
subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument");
}
// Self-accepting HMR: tear down the old instance and re-create with the new module's code

View File

@@ -2,15 +2,14 @@ import { writable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files";
import { storeDocumentTabOrder } from "/src/utility-functions/persistence";
import { rasterizeSVG } from "/src/utility-functions/rasterization";
import type { EditorWrapper, OpenDocument, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { EditorWrapper, DocumentInfo, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
export type PortfolioStore = ReturnType<typeof createPortfolioStore>;
type PortfolioStoreState = {
unsaved: boolean;
documents: OpenDocument[];
documents: DocumentInfo[];
activeDocumentIndex: number;
panelLayout: WorkspacePanelLayout;
};
@@ -38,7 +37,6 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
state.documents = data.openDocuments;
return state;
});
storeDocumentTabOrder({ subscribe });
});
subscriptions.subscribeFrontendMessage("UpdateActiveDocument", (data) => {

View File

@@ -247,7 +247,7 @@ export function onModifyInputField(e: CustomEvent) {
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper, portfolioStore: PortfolioStore) {
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
if (activeDocument && !activeDocument.details.is_auto_saved) editor.triggerAutoSave(activeDocument.id);
if (activeDocument) editor.triggerAutoSave(activeDocument.id);
// Skip the message if the editor crashed, since work is already lost
if (await editor.hasCrashed()) return;
@@ -255,7 +255,7 @@ export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper
// Skip the message during development, since it's annoying when testing
if (await editor.inDevelopmentMode()) return;
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.is_saved, true);
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.is_saved, true);
if (!allDocumentsSaved) {
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
e.preventDefault();

View File

@@ -1,25 +1,23 @@
import { get } from "svelte/store";
import type { PortfolioStore } from "/src/stores/portfolio";
import type { MessageBody } from "/src/subscriptions-router";
import type { EditorWrapper, PersistedDocumentInfo, PersistedState } from "/wrapper/pkg/graphite_wasm_wrapper";
import type { DocumentInfo, EditorWrapper, PersistedState } from "/wrapper/pkg/graphite_wasm_wrapper";
const PERSISTENCE_DB = "graphite";
const PERSISTENCE_STORE = "store";
function emptyPersistedState(): PersistedState {
// eslint-disable-next-line camelcase
return { documents: [], current_document: undefined };
return { documents: [], current_document: undefined, workspace_layout: undefined };
}
function createDocumentInfo(id: bigint, name: string, isSaved: boolean): PersistedDocumentInfo {
function createDocumentInfo(id: bigint, name: string, isSaved: boolean): DocumentInfo {
// eslint-disable-next-line camelcase
return { id, name, is_saved: isSaved };
}
// Reorder document entries to match the given ID ordering, appending any unmentioned entries at the end
function reorderDocuments(documents: PersistedDocumentInfo[], orderedIds: bigint[]): PersistedDocumentInfo[] {
function reorderDocuments(documents: DocumentInfo[], orderedIds: bigint[]): DocumentInfo[] {
const byId = new Map(documents.map((entry) => [entry.id, entry]));
const reordered: PersistedDocumentInfo[] = [];
const reordered: DocumentInfo[] = [];
orderedIds.forEach((id) => {
const existing = byId.get(id);
@@ -39,18 +37,8 @@ function reorderDocuments(documents: PersistedDocumentInfo[], orderedIds: bigint
// State-based persistence (new format)
// ====================================
export async function storeDocumentTabOrder(portfolio: PortfolioStore) {
const portfolioData = get(portfolio);
const orderedIds = portfolioData.documents.map((doc) => doc.id);
await databaseUpdate<PersistedState>("state", (old) => {
const state = old || emptyPersistedState();
return { ...state, documents: reorderDocuments(state.documents, orderedIds) };
});
}
export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
const { documentId, document, details } = autoSaveDocument;
export async function writePersistedDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">) {
const { documentId, document } = autoSaveDocument;
// Update content in the documents store
await databaseUpdate<Record<string, string>>("documents", (old) => {
@@ -58,91 +46,44 @@ export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersis
documents[String(documentId)] = document;
return documents;
});
// Update metadata and ordering in the state store
const portfolioData = get(portfolio);
const orderedIds = portfolioData.documents.map((doc) => doc.id);
await databaseUpdate<PersistedState>("state", (old) => {
const state = old || emptyPersistedState();
// Update (or add) the document info entry
const entry = createDocumentInfo(documentId, details.name, details.is_saved);
const existingIndex = state.documents.findIndex((doc) => doc.id === documentId);
if (existingIndex !== -1) {
state.documents[existingIndex] = entry;
} else {
state.documents.push(entry);
}
// eslint-disable-next-line camelcase
state.current_document = documentId;
state.documents = reorderDocuments(state.documents, orderedIds);
return state;
});
}
export async function removeDocument(id: string, portfolio: PortfolioStore) {
const documentId = BigInt(id);
export async function readPersistedDocument(documentId: bigint, editor: EditorWrapper) {
const documentContents = await databaseGet<Record<string, string>>("documents");
if (!documentContents) return;
const content = documentContents[String(documentId)];
if (content === undefined) return;
editor.loadDocumentContent(documentId, content);
}
export async function deletePersistedDocument(id: string) {
// Remove content from the documents store
await databaseUpdate<Record<string, string>>("documents", (old) => {
const documents = old || {};
delete documents[id];
return documents;
});
// Update state: remove the entry and update current_document
const portfolioData = get(portfolio);
const documentCount = portfolioData.documents.length;
await databaseUpdate<PersistedState>("state", (old) => {
const state: PersistedState = old || emptyPersistedState();
state.documents = state.documents.filter((doc) => doc.id !== documentId);
if (state.current_document === documentId) {
// eslint-disable-next-line camelcase
state.current_document = documentCount > 0 ? portfolioData.documents[portfolioData.activeDocumentIndex].id : undefined;
}
return state;
});
}
export async function loadDocuments(editor: EditorWrapper) {
export async function writePersistedState(state: PersistedState) {
// Keep state ordered and normalized before writing.
state.documents = reorderDocuments(
state.documents,
state.documents.map((entry) => entry.id),
);
await databaseSet("state", state);
await garbageCollectDocuments();
}
export async function readPersistedState(editor: EditorWrapper) {
await migrateToNewFormat();
await garbageCollectDocuments();
const state = await databaseGet<PersistedState>("state");
const documentContents = await databaseGet<Record<string, string>>("documents");
if (!state || !documentContents || state.documents.length === 0) return;
// Find the current document (or fall back to the last document in the list)
const currentId = state.current_document;
const currentEntry = currentId !== undefined ? state.documents.find((doc) => doc.id === currentId) : undefined;
const current = currentEntry || state.documents[state.documents.length - 1];
// Open all documents in persisted tab order, then select the current one
state.documents.forEach((entry) => {
const content = documentContents[String(entry.id)];
if (content === undefined) return;
editor.openAutoSavedDocument(entry.id, entry.name, entry.is_saved, content, false);
});
editor.selectDocument(current.id);
}
export async function saveActiveDocument(documentId: bigint) {
await databaseUpdate<PersistedState>("state", (old) => {
const state: PersistedState = old || emptyPersistedState();
const exists = state.documents.some((doc) => doc.id === documentId);
// eslint-disable-next-line camelcase
if (exists) state.current_document = documentId;
return state;
});
if (!state) return;
editor.loadPersistedState(state);
}
export async function saveEditorPreferences(preferences: unknown) {
@@ -154,15 +95,6 @@ export async function loadEditorPreferences(editor: EditorWrapper) {
editor.loadPreferences(preferences ? JSON.stringify(preferences) : undefined);
}
export async function saveWorkspaceLayout(layout: unknown) {
await databaseSet("workspace_layout", layout);
}
export async function loadWorkspaceLayout(editor: EditorWrapper) {
const layout = await databaseGet<Record<string, unknown>>("workspace_layout");
if (layout) editor.loadWorkspaceLayout(layout);
}
// Remove orphaned entries from the "documents" content store that have no corresponding entry in "state"
async function garbageCollectDocuments() {
const state = await databaseGet<PersistedState>("state");
@@ -197,6 +129,7 @@ export async function wipeDocuments() {
async function wipeOldFormat() {
await databaseDelete("documents_tab_order");
await databaseDelete("current_document_id");
await databaseDelete("workspace_layout");
}
// TODO: Eventually remove this document upgrade code
@@ -209,7 +142,7 @@ async function migrateToNewFormat() {
// Build the new "state" and "documents" from the old format
const newDocumentContents: Record<string, string> = {};
const newDocumentInfos: PersistedDocumentInfo[] = [];
const newDocumentInfos: DocumentInfo[] = [];
if (oldDocuments) {
Object.values(oldDocuments).forEach((value) => {