mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 02:48:12 +08:00
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:
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user