diff --git a/desktop/src/app.rs b/desktop/src/app.rs index e6812b7abe..303f96713b 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -314,39 +314,19 @@ impl App { responses.push(message); } } - DesktopFrontendMessage::PersistenceLoadCurrentDocument => { - if let Some((id, document)) = self.persistent_data.current_document() { - let message = DesktopWrapperMessage::LoadDocument { - id, - document, - to_front: false, - select_after_open: true, - }; - responses.push(message); - } - } - DesktopFrontendMessage::PersistenceLoadRemainingDocuments => { - for (id, document) in self.persistent_data.documents_before_current().into_iter().rev() { - let message = DesktopWrapperMessage::LoadDocument { - id, - document, - to_front: true, - select_after_open: false, - }; - responses.push(message); - } - for (id, document) in self.persistent_data.documents_after_current() { - let message = DesktopWrapperMessage::LoadDocument { + DesktopFrontendMessage::PersistenceLoadDocuments => { + // Open all documents in persisted tab order, then select the current one + for (id, document) in self.persistent_data.all_documents() { + responses.push(DesktopWrapperMessage::LoadDocument { id, document, to_front: false, select_after_open: false, - }; - responses.push(message); + }); } + if let Some(id) = self.persistent_data.current_document_id() { - let message = DesktopWrapperMessage::SelectDocument { id }; - responses.push(message); + responses.push(DesktopWrapperMessage::SelectDocument { id }); } } DesktopFrontendMessage::OpenLaunchDocuments => { diff --git a/desktop/src/cef/dirs.rs b/desktop/src/cef/dirs.rs index 5046fc6e78..6ba6f2362a 100644 --- a/desktop/src/cef/dirs.rs +++ b/desktop/src/cef/dirs.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use crate::dirs::{app_data_dir, ensure_dir_exists}; -static CEF_DIR_NAME: &str = "browser"; +static CEF_DIR_NAME: &str = "cef"; pub(crate) fn delete_instance_dirs() { let cef_dir = app_data_dir().join(CEF_DIR_NAME); diff --git a/desktop/src/persist.rs b/desktop/src/persist.rs index bed6cc2dcc..5860f266a7 100644 --- a/desktop/src/persist.rs +++ b/desktop/src/persist.rs @@ -1,8 +1,9 @@ -use crate::wrapper::messages::{Document, DocumentId}; +use crate::wrapper::messages::{Document, DocumentId, PersistedDocumentInfo}; +// Wraps PersistedState (shared with the web frontend) and adds desktop-specific behavior like file I/O #[derive(Default, serde::Serialize, serde::Deserialize)] pub(crate) struct PersistentData { - documents: DocumentStore, + documents: Vec, current_document: Option, #[serde(skip)] document_order: Option>, @@ -10,9 +11,26 @@ pub(crate) struct PersistentData { impl PersistentData { pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) { - self.documents.write(id, document); + // Update or add the document metadata + let info = PersistedDocumentInfo { + id, + name: document.name.clone(), + path: document.path.clone(), + is_saved: document.is_saved, + }; + if let Some(existing) = self.documents.iter_mut().find(|doc| doc.id == id) { + *existing = info; + } else { + self.documents.push(info); + } + + // Write the document content to a separate file + if let Err(e) = std::fs::write(Self::document_content_path(&id), document.content) { + tracing::error!("Failed to write document {id:?} to disk: {e}"); + } + if let Some(order) = &self.document_order { - self.documents.force_order(order); + self.force_order(&order.clone()); } self.flush(); } @@ -21,45 +39,24 @@ impl PersistentData { if Some(*id) == self.current_document { self.current_document = None; } - self.documents.delete(id); + + self.documents.retain(|doc| doc.id != *id); + if let Err(e) = std::fs::remove_file(Self::document_content_path(id)) { + tracing::error!("Failed to delete document {id:?} from disk: {e}"); + } + self.flush(); } pub(crate) fn current_document_id(&self) -> Option { match self.current_document { Some(id) => Some(id), - None => Some(*self.documents.document_ids().first()?), + None => Some(self.documents.first()?.id), } } - pub(crate) fn current_document(&self) -> Option<(DocumentId, Document)> { - let current_id = self.current_document_id()?; - Some((current_id, self.documents.read(¤t_id)?)) - } - - pub(crate) fn documents_before_current(&self) -> Vec<(DocumentId, Document)> { - let Some(current_id) = self.current_document_id() else { - return Vec::new(); - }; - self.documents - .document_ids() - .into_iter() - .take_while(|id| *id != current_id) - .filter_map(|id| Some((id, self.documents.read(&id)?))) - .collect() - } - - pub(crate) fn documents_after_current(&self) -> Vec<(DocumentId, Document)> { - let Some(current_id) = self.current_document_id() else { - return Vec::new(); - }; - self.documents - .document_ids() - .into_iter() - .skip_while(|id| *id != current_id) - .skip(1) - .filter_map(|id| Some((id, self.documents.read(&id)?))) - .collect() + pub(crate) fn all_documents(&self) -> Vec<(DocumentId, Document)> { + self.documents.iter().filter_map(|doc| Some((doc.id, self.read_document(&doc.id)?))).collect() } pub(crate) fn set_current_document(&mut self, id: DocumentId) { @@ -68,11 +65,37 @@ impl PersistentData { } pub(crate) fn force_document_order(&mut self, order: Vec) { + self.force_order(&order); self.document_order = Some(order); - self.documents.force_order(self.document_order.as_ref().unwrap()); self.flush(); } + // Reads serialized document content from disk and combines it with the stored metadata + fn read_document(&self, id: &DocumentId) -> Option { + let info = self.documents.iter().find(|doc| doc.id == *id)?; + let content = std::fs::read_to_string(Self::document_content_path(id)).ok()?; + Some(Document { + content, + name: info.name.clone(), + path: info.path.clone(), + is_saved: info.is_saved, + }) + } + + // Reorders the documents array to match a desired ordering, keeping unmentioned documents at the end + fn force_order(&mut self, desired_order: &[DocumentId]) { + let mut ordered_prefix_length = 0; + for id in desired_order { + if let Some(offset) = self.documents[ordered_prefix_length..].iter().position(|doc| doc.id == *id) { + let found_index = ordered_prefix_length + offset; + if found_index != ordered_prefix_length { + self.documents[ordered_prefix_length..=found_index].rotate_right(1); + } + ordered_prefix_length += 1; + } + } + } + fn flush(&self) { let data = match ron::ser::to_string_pretty(self, Default::default()) { Ok(d) => d, @@ -107,86 +130,52 @@ impl PersistentData { } }; *self = loaded; - } - fn state_file_path() -> std::path::PathBuf { - let mut path = crate::dirs::app_data_dir(); - path.push(crate::consts::APP_STATE_FILE_NAME); - path + self.garbage_collect_document_files(); + Self::delete_old_cef_browser_directory(); } -} -#[derive(Default, serde::Serialize, serde::Deserialize)] -struct DocumentStore(Vec); -impl DocumentStore { - fn write(&mut self, id: DocumentId, document: Document) { - let meta = DocumentInfo::new(id, &document); - if let Some(existing) = self.0.iter_mut().find(|meta| meta.id == id) { - *existing = meta; - } else { - self.0.push(meta); - } - if let Err(e) = std::fs::write(Self::document_path(&id), document.content) { - tracing::error!("Failed to write document {id:?} to disk: {e}"); - } - } + // Remove orphaned document content files that have no corresponding entry in the persisted state + fn garbage_collect_document_files(&self) { + let valid_paths: std::collections::HashSet<_> = self.documents.iter().map(|doc| Self::document_content_path(&doc.id)).collect(); - fn delete(&mut self, id: &DocumentId) { - self.0.retain(|meta| meta.id != *id); - if let Err(e) = std::fs::remove_file(Self::document_path(id)) { - tracing::error!("Failed to delete document {id:?} from disk: {e}"); - } - } - - fn read(&self, id: &DocumentId) -> Option { - let meta = self.0.iter().find(|meta| meta.id == *id)?; - let content = std::fs::read_to_string(Self::document_path(id)).ok()?; - Some(Document { - content, - name: meta.name.clone(), - path: meta.path.clone(), - is_saved: meta.is_saved, - }) - } + let directory = crate::dirs::app_autosave_documents_dir(); + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::error!("Failed to read autosave documents directory: {e}"); + return; + } + }; - fn force_order(&mut self, desired_order: &[DocumentId]) { - let mut ordered_prefix_len = 0; - for id in desired_order { - if let Some(offset) = self.0[ordered_prefix_len..].iter().position(|meta| meta.id == *id) { - let found_index = ordered_prefix_len + offset; - if found_index != ordered_prefix_len { - self.0[ordered_prefix_len..=found_index].rotate_right(1); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && !valid_paths.contains(&path) { + if let Err(e) = std::fs::remove_file(&path) { + tracing::error!("Failed to remove orphaned document file {path:?}: {e}"); } - ordered_prefix_len += 1; } } } - fn document_ids(&self) -> Vec { - self.0.iter().map(|meta| meta.id).collect() + // TODO: Eventually remove this cleanup code for the old "browser" CEF directory (renamed to "cef") + fn delete_old_cef_browser_directory() { + let old_browser_dir = crate::dirs::app_data_dir().join("browser"); + if old_browser_dir.is_dir() { + let _ = std::fs::remove_dir_all(&old_browser_dir); + } } - fn document_path(id: &DocumentId) -> std::path::PathBuf { - let mut path = crate::dirs::app_autosave_documents_dir(); - path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION)); + fn state_file_path() -> std::path::PathBuf { + let mut path = crate::dirs::app_data_dir(); + path.push(crate::consts::APP_STATE_FILE_NAME); path } -} -#[derive(serde::Serialize, serde::Deserialize)] -struct DocumentInfo { - id: DocumentId, - name: String, - path: Option, - is_saved: bool, -} -impl DocumentInfo { - fn new(id: DocumentId, Document { name, path, is_saved, .. }: &Document) -> Self { - Self { - id, - name: name.clone(), - path: path.clone(), - is_saved: *is_saved, - } + fn document_content_path(id: &DocumentId) -> std::path::PathBuf { + let mut path = crate::dirs::app_autosave_documents_dir(); + path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION)); + path } } diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index a5fd66c23f..b8a0977412 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -71,9 +71,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument { id: document_id, document: Document { + content: document, name: details.name, path: details.path, - content: document, is_saved: details.is_saved, }, }); @@ -95,11 +95,8 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD // Forward this to update the UI return Some(FrontendMessage::UpdateOpenDocumentsList { open_documents }); } - FrontendMessage::TriggerLoadFirstAutoSaveDocument => { - dispatcher.respond(DesktopFrontendMessage::PersistenceLoadCurrentDocument); - } - FrontendMessage::TriggerLoadRestAutoSaveDocuments => { - dispatcher.respond(DesktopFrontendMessage::PersistenceLoadRemainingDocuments); + FrontendMessage::TriggerLoadAutoSaveDocuments => { + dispatcher.respond(DesktopFrontendMessage::PersistenceLoadDocuments); } FrontendMessage::TriggerOpenLaunchDocuments => { dispatcher.respond(DesktopFrontendMessage::OpenLaunchDocuments); diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index b067a4eee3..f9c5082c98 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; +pub use graphite_editor::messages::frontend::utility_types::{PersistedDocumentInfo, PersistedState}; pub use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys}; pub use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState as MouseState, EditorPosition as Position, MouseKeys}; pub use graphite_editor::messages::prelude::DocumentId; @@ -49,8 +50,7 @@ pub enum DesktopFrontendMessage { PersistenceUpdateCurrentDocument { id: DocumentId, }, - PersistenceLoadCurrentDocument, - PersistenceLoadRemainingDocuments, + PersistenceLoadDocuments, PersistenceUpdateDocumentsList { ids: Vec, }, diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 3ea4b3f403..c9b7782f91 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -123,8 +123,7 @@ pub enum FrontendMessage { document: String, details: DocumentDetails, }, - TriggerLoadFirstAutoSaveDocument, - TriggerLoadRestAutoSaveDocuments, + TriggerLoadAutoSaveDocuments, TriggerOpenLaunchDocuments, TriggerLoadPreferences, TriggerLoadWorkspaceLayout, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 979a3d0221..04ce8cba77 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -15,12 +15,29 @@ pub struct OpenDocument { pub struct DocumentDetails { pub name: String, pub path: Option, - #[serde(rename = "isSaved")] + #[serde(alias = "isSaved")] pub is_saved: bool, - #[serde(rename = "isAutoSaved")] + #[serde(alias = "isAutoSaved")] pub is_auto_saved: bool, } +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PersistedDocumentInfo { + pub id: DocumentId, + pub name: String, + #[serde(default)] + pub path: Option, + pub is_saved: bool, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PersistedState { + pub documents: Vec, + pub current_document: Option, +} + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum MouseCursorIcon { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ef03f7d1cf..53019b9b84 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -109,8 +109,11 @@ impl MessageHandler> for Portfolio // Before loading any documents, initially prepare the welcome screen buttons layout responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); - // Tell frontend to load the current document - responses.add(FrontendMessage::TriggerLoadFirstAutoSaveDocument); + // Tell frontend to load persistent auto-saved documents (placed early so IndexedDB reads overlap with subsequent UI setup) + responses.add(FrontendMessage::TriggerLoadAutoSaveDocuments); + + // Tell frontend to load documents passed in as launch arguments + responses.add(FrontendMessage::TriggerOpenLaunchDocuments); // Display the menu bar at the top of the window responses.add(MenuBarMessage::SendLayout); @@ -118,11 +121,8 @@ impl MessageHandler> for Portfolio // Send the initial workspace panel layout to the frontend responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); - // Send the information for tooltips and categories for each node/input. - responses.add(FrontendMessage::SendUIMetadata { - node_descriptions: document_node_definitions::collect_node_descriptions(), - node_types: document_node_definitions::collect_node_types(), - }); + // Request status bar info layout + responses.add(PortfolioMessage::RequestStatusBarInfoLayout); // Send shortcuts for widgets created in the frontend which need shortcut tooltips responses.add(FrontendMessage::SendShortcutFullscreen { @@ -136,14 +136,11 @@ impl MessageHandler> for Portfolio shortcut: action_shortcut_manual!(Key::Shift, Key::MouseLeft), }); - // Request status bar info layout - responses.add(PortfolioMessage::RequestStatusBarInfoLayout); - - // Tell frontend to finish loading persistent documents - responses.add(FrontendMessage::TriggerLoadRestAutoSaveDocuments); - - // Tell frontend to load documented passed in as launch arguments - responses.add(FrontendMessage::TriggerOpenLaunchDocuments); + // Send the information for tooltips and categories for each node/input. + responses.add(FrontendMessage::SendUIMetadata { + node_descriptions: document_node_definitions::collect_node_descriptions(), + node_types: document_node_definitions::collect_node_types(), + }); } PortfolioMessage::DocumentPassMessage { document_id, message } => { if let Some(document) = self.documents.get_mut(&document_id) { diff --git a/frontend/src/components/window/PanelSubdivision.svelte b/frontend/src/components/window/PanelSubdivision.svelte index f8d2c0299c..fe1ee0dd1e 100644 --- a/frontend/src/components/window/PanelSubdivision.svelte +++ b/frontend/src/components/window/PanelSubdivision.svelte @@ -33,7 +33,7 @@ $: 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.isSaved; + const unsaved = !doc.details.is_saved; if (!editor.inDevelopmentMode()) return { name, unsaved }; const tooltipDescription = `Document ID: ${doc.id}`; diff --git a/frontend/src/managers/persistence.ts b/frontend/src/managers/persistence.ts index 1ea3fdbc77..4907891f46 100644 --- a/frontend/src/managers/persistence.ts +++ b/frontend/src/managers/persistence.ts @@ -7,8 +7,7 @@ import { loadWorkspaceLayout, storeDocument, removeDocument, - loadFirstDocument, - loadRestDocuments, + loadDocuments, saveActiveDocument, } from "/src/utility-functions/persistence"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -48,12 +47,8 @@ export function createPersistenceManager(subscriptions: SubscriptionsRouter, edi await removeDocument(String(data.documentId), portfolio); }); - subscriptions.subscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument", async () => { - await loadFirstDocument(editor); - }); - - subscriptions.subscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments", async () => { - await loadRestDocuments(editor); + subscriptions.subscribeFrontendMessage("TriggerLoadAutoSaveDocuments", async () => { + await loadDocuments(editor); }); subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => { @@ -75,8 +70,7 @@ export function destroyPersistenceManager() { subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout"); subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument"); subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument"); - subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument"); - subscriptions.unsubscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments"); + subscriptions.unsubscribeFrontendMessage("TriggerLoadAutoSaveDocuments"); subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments"); subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument"); } diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 330a3bcbf4..1bbb04ad16 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -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.isAutoSaved) editor.triggerAutoSave(activeDocument.id); + if (activeDocument && !activeDocument.details.is_auto_saved) 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.isSaved, true); + const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.is_saved, true); if (!allDocumentsSaved) { e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?"; e.preventDefault(); diff --git a/frontend/src/utility-functions/persistence.ts b/frontend/src/utility-functions/persistence.ts index 01ae129999..921f054a2a 100644 --- a/frontend/src/utility-functions/persistence.ts +++ b/frontend/src/utility-functions/persistence.ts @@ -1,155 +1,148 @@ import { get } from "svelte/store"; import type { PortfolioStore } from "/src/stores/portfolio"; import type { MessageBody } from "/src/subscriptions-router"; -import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { EditorWrapper, PersistedDocumentInfo, PersistedState } from "/wrapper/pkg/graphite_wasm_wrapper"; const PERSISTENCE_DB = "graphite"; const PERSISTENCE_STORE = "store"; -export async function storeDocumentTabOrder(portfolio: PortfolioStore) { - const documentOrder = get(portfolio).documents.map((doc) => String(doc.id)); - await databaseSet("documents_tab_order", documentOrder); +function emptyPersistedState(): PersistedState { + // eslint-disable-next-line camelcase + return { documents: [], current_document: undefined }; +} + +function createDocumentInfo(id: bigint, name: string, isSaved: boolean): PersistedDocumentInfo { + // 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[] { + const byId = new Map(documents.map((entry) => [entry.id, entry])); + const reordered: PersistedDocumentInfo[] = []; + + orderedIds.forEach((id) => { + const existing = byId.get(id); + if (existing) { + reordered.push(existing); + byId.delete(id); + } + }); + + // Append any entries not yet present in the portfolio (e.g. documents still loading at startup) + byId.forEach((entry) => reordered.push(entry)); + + return reordered; } -export async function storeCurrentDocumentId(documentId: string) { - await databaseSet("current_document_id", String(documentId)); +// ==================================== +// 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("state", (old) => { + const state = old || emptyPersistedState(); + return { ...state, documents: reorderDocuments(state.documents, orderedIds) }; + }); } export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) { - await databaseUpdate>>("documents", (old) => { + const { documentId, document, details } = autoSaveDocument; + + // Update content in the documents store + await databaseUpdate>("documents", (old) => { const documents = old || {}; - documents[String(autoSaveDocument.documentId)] = autoSaveDocument; + documents[String(documentId)] = document; return documents; }); - await storeDocumentTabOrder(portfolio); - await storeCurrentDocumentId(String(autoSaveDocument.documentId)); + // Update metadata and ordering in the state store + const portfolioData = get(portfolio); + const orderedIds = portfolioData.documents.map((doc) => doc.id); + + await databaseUpdate("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) { - await databaseUpdate>>("documents", (old) => { + const documentId = BigInt(id); + + // Remove content from the documents store + await databaseUpdate>("documents", (old) => { const documents = old || {}; delete documents[id]; return documents; }); - await databaseUpdate("documents_tab_order", (old) => { - const order = old || []; - return order.filter((docId) => docId !== id); - }); + // Update state: remove the entry and update current_document + const portfolioData = get(portfolio); + const documentCount = portfolioData.documents.length; - const documentCount = get(portfolio).documents.length; - if (documentCount > 0) { - const documentIndex = get(portfolio).activeDocumentIndex; - const documentId = String(get(portfolio).documents[documentIndex].id); + await databaseUpdate("state", (old) => { + const state: PersistedState = old || emptyPersistedState(); + state.documents = state.documents.filter((doc) => doc.id !== documentId); - const tabOrder = (await databaseGet("documents_tab_order")) || []; - if (tabOrder.includes(documentId)) { - await storeCurrentDocumentId(documentId); + if (state.current_document === documentId) { + // eslint-disable-next-line camelcase + state.current_document = documentCount > 0 ? portfolioData.documents[portfolioData.activeDocumentIndex].id : undefined; } - } else { - await databaseDelete("current_document_id"); - } -} - -export async function loadFirstDocument(editor: EditorWrapper) { - const previouslySavedDocuments = await databaseGet>>("documents"); - // TODO: Eventually remove this document upgrade code - // Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings - if (previouslySavedDocuments) { - Object.values(previouslySavedDocuments).forEach((doc) => { - if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId); - }); - } - - const documentOrder = await databaseGet("documents_tab_order"); - const currentDocumentIdString = await databaseGet("current_document_id"); - const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined; - if (!previouslySavedDocuments || !documentOrder) return; - - const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : [])); - - if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) { - const doc = previouslySavedDocuments[String(currentDocumentId)]; - editor.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false); - editor.selectDocument(currentDocumentId); - } else { - const len = orderedSavedDocuments.length; - if (len > 0) { - const doc = orderedSavedDocuments[len - 1]; - editor.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false); - editor.selectDocument(doc.documentId); - } - } + return state; + }); } -export async function loadRestDocuments(editor: EditorWrapper) { - const previouslySavedDocuments = await databaseGet>>("documents"); - - // TODO: Eventually remove this document upgrade code - // Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed - if (previouslySavedDocuments) { - Object.values(previouslySavedDocuments).forEach((doc) => { - if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId); - }); - } - - const documentOrder = await databaseGet("documents_tab_order"); - const currentDocumentIdString = await databaseGet("current_document_id"); - const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined; - if (!previouslySavedDocuments || !documentOrder) return; +export async function loadDocuments(editor: EditorWrapper) { + await migrateToNewFormat(); + await garbageCollectDocuments(); - const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : [])); + const state = await databaseGet("state"); + const documentContents = await databaseGet>("documents"); + if (!state || !documentContents || state.documents.length === 0) return; - const currentIndex = currentDocumentId !== undefined ? orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId) : -1; + // 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 documents in order around the current document, placing earlier ones before it and later ones after - if (currentIndex !== -1 && currentDocumentId !== undefined) { - for (let i = currentIndex - 1; i >= 0; i--) { - const { documentId, document, details } = orderedSavedDocuments[i]; - const { name, isSaved } = details; - editor.openAutoSavedDocument(documentId, name, isSaved, document, true); - } - for (let i = currentIndex + 1; i < orderedSavedDocuments.length; i++) { - const { documentId, document, details } = orderedSavedDocuments[i]; - const { name, isSaved } = details; - editor.openAutoSavedDocument(documentId, name, isSaved, document, false); - } + // 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.selectDocument(currentDocumentId); - } - // No valid current document: open all remaining documents and select the last one - else { - const length = orderedSavedDocuments.length; - - for (let i = length - 2; i >= 0; i--) { - const { documentId, document, details } = orderedSavedDocuments[i]; - const { name, isSaved } = details; - editor.openAutoSavedDocument(documentId, name, isSaved, document, true); - } + editor.openAutoSavedDocument(entry.id, entry.name, entry.is_saved, content, false); + }); - if (length > 0) editor.selectDocument(orderedSavedDocuments[length - 1].documentId); - } + editor.selectDocument(current.id); } export async function saveActiveDocument(documentId: bigint) { - const previouslySavedDocuments = await databaseGet>>("documents"); + await databaseUpdate("state", (old) => { + const state: PersistedState = old || emptyPersistedState(); - const documentIdString = String(documentId); + const exists = state.documents.some((doc) => doc.id === documentId); + // eslint-disable-next-line camelcase + if (exists) state.current_document = documentId; - // TODO: Eventually remove this document upgrade code - // Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed - if (previouslySavedDocuments) { - Object.values(previouslySavedDocuments).forEach((doc) => { - if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId); - }); - } - - if (!previouslySavedDocuments) return; - if (documentIdString in previouslySavedDocuments) { - await storeCurrentDocumentId(documentIdString); - } + return state; + }); } export async function saveEditorPreferences(preferences: unknown) { @@ -170,12 +163,118 @@ export async function loadWorkspaceLayout(editor: EditorWrapper) { 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("state"); + const documentContents = await databaseGet>("documents"); + if (!documentContents) return; + + const validIds = new Set(state ? state.documents.map((doc) => String(doc.id)) : []); + let changed = false; + + Object.keys(documentContents).forEach((key) => { + if (!validIds.has(key)) { + delete documentContents[key]; + changed = true; + } + }); + + if (changed) await databaseSet("documents", documentContents); +} + export async function wipeDocuments() { + await databaseDelete("state"); + await databaseDelete("documents"); + + await wipeOldFormat(); +} + +// ========================= +// Migration from old format +// ========================= + +// TODO: Eventually remove this document upgrade code +async function wipeOldFormat() { await databaseDelete("documents_tab_order"); await databaseDelete("current_document_id"); - await databaseDelete("documents"); } +// TODO: Eventually remove this document upgrade code +async function migrateToNewFormat() { + // Detect the old format by checking for the existence of the "documents_tab_order" key + const oldTabOrder = await databaseGet("documents_tab_order"); + if (oldTabOrder === undefined) return; + + const oldDocuments = await databaseGet>("documents"); + + // Build the new "state" and "documents" from the old format + const newDocumentContents: Record = {}; + const newDocumentInfos: PersistedDocumentInfo[] = []; + + if (oldDocuments) { + Object.values(oldDocuments).forEach((value) => { + const oldEntry: unknown = value; + if (typeof oldEntry !== "object" || oldEntry === null) return; + if (!("documentId" in oldEntry) || !("document" in oldEntry) || !("details" in oldEntry)) return; + + // Extract the document ID, handling bigint, number, and string formats + let id: bigint; + if (typeof oldEntry.documentId === "bigint") { + id = oldEntry.documentId; + } else if (typeof oldEntry.documentId === "number") { + id = BigInt(oldEntry.documentId); + } else if (typeof oldEntry.documentId === "string") { + id = BigInt(oldEntry.documentId); + } else { + return; + } + + // Extract the document content + if (typeof oldEntry.document !== "string") return; + newDocumentContents[String(id)] = oldEntry.document; + + // Extract document details, handling camelCase from the old shipped format + const details: unknown = oldEntry.details; + if (typeof details !== "object" || details === null) return; + + let name = ""; + if ("name" in details && typeof details.name === "string") name = details.name; + + const isSaved = extractIsSavedFromUnknown(details); + + newDocumentInfos.push(createDocumentInfo(id, name, isSaved)); + }); + } + + const newState = emptyPersistedState(); + newState.documents = newDocumentInfos; + + // Write the new format + await databaseSet("state", newState); + await databaseSet("documents", newDocumentContents); + + // Delete old keys + await databaseDelete("documents_tab_order"); + await databaseDelete("current_document_id"); +} + +// TODO: Eventually remove this document upgrade code +function extractIsSavedFromUnknown(details: unknown): boolean { + if (typeof details !== "object" || details === null) return false; + + // Old camelCase format + if ("isSaved" in details) return Boolean(details.isSaved); + + // New snake_case format + if ("is_saved" in details) return Boolean(details.is_saved); + + return false; +} + +// ================= +// IndexedDB helpers +// ================= + function databaseOpen(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(PERSISTENCE_DB, 1);