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 09:31:21 +00:00
committed by GitHub
parent 2a2a60883d
commit 6c5e3c97f8
36 changed files with 562 additions and 699 deletions
+2 -2
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();
+32 -99
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) => {