Refactor IndexedDB document persistence to reuse data structures of desktop persistence (#4020)

* Switch indexedDb document serialized struct from camelCase to snake_case

* Refactor and migrate old indexedDb format to the same shape as desktop persistence

* Avoid duplicate struct definitions in the desktop crate

* Refactor frontend message handling to consolidate auto-save document loading

* Code review

* Review

---------

Co-authored-by: Timon <me@timon.zip>
This commit is contained in:
Keavon Chambers 2026-04-15 02:29:23 -07:00 committed by GitHub
parent 79d778a535
commit 60f16d72a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 345 additions and 295 deletions

View File

@ -314,39 +314,19 @@ impl App {
responses.push(message); responses.push(message);
} }
} }
DesktopFrontendMessage::PersistenceLoadCurrentDocument => { DesktopFrontendMessage::PersistenceLoadDocuments => {
if let Some((id, document)) = self.persistent_data.current_document() { // Open all documents in persisted tab order, then select the current one
let message = DesktopWrapperMessage::LoadDocument { for (id, document) in self.persistent_data.documents() {
id, responses.push(DesktopWrapperMessage::LoadDocument {
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 {
id, id,
document, document,
to_front: false, to_front: false,
select_after_open: false, select_after_open: false,
}; });
responses.push(message);
} }
if let Some(id) = self.persistent_data.current_document_id() { if let Some(id) = self.persistent_data.current_document_id() {
let message = DesktopWrapperMessage::SelectDocument { id }; responses.push(DesktopWrapperMessage::SelectDocument { id });
responses.push(message);
} }
} }
DesktopFrontendMessage::OpenLaunchDocuments => { DesktopFrontendMessage::OpenLaunchDocuments => {

View File

@ -1,8 +1,8 @@
use crate::wrapper::messages::{Document, DocumentId}; use crate::wrapper::messages::{Document, DocumentId, PersistedDocumentInfo};
#[derive(Default, serde::Serialize, serde::Deserialize)] #[derive(Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct PersistentData { pub(crate) struct PersistentData {
documents: DocumentStore, documents: Vec<PersistedDocumentInfo>,
current_document: Option<DocumentId>, current_document: Option<DocumentId>,
#[serde(skip)] #[serde(skip)]
document_order: Option<Vec<DocumentId>>, document_order: Option<Vec<DocumentId>>,
@ -10,10 +10,22 @@ pub(crate) struct PersistentData {
impl PersistentData { impl PersistentData {
pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) { pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) {
self.documents.write(id, document); let info = PersistedDocumentInfo {
if let Some(order) = &self.document_order { id,
self.documents.force_order(order); 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);
} }
if let Err(e) = std::fs::write(Self::document_content_path(&id), document.content) {
tracing::error!("Failed to write document {id:?} to disk: {e}");
}
self.flush(); self.flush();
} }
@ -21,45 +33,24 @@ impl PersistentData {
if Some(*id) == self.current_document { if Some(*id) == self.current_document {
self.current_document = None; 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(); self.flush();
} }
pub(crate) fn current_document_id(&self) -> Option<DocumentId> { pub(crate) fn current_document_id(&self) -> Option<DocumentId> {
match self.current_document { match self.current_document {
Some(id) => Some(id), 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)> { pub(crate) fn documents(&self) -> Vec<(DocumentId, Document)> {
let current_id = self.current_document_id()?; self.documents.iter().filter_map(|doc| Some((doc.id, self.read_document(&doc.id)?))).collect()
Some((current_id, self.documents.read(&current_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 set_current_document(&mut self, id: DocumentId) { pub(crate) fn set_current_document(&mut self, id: DocumentId) {
@ -68,11 +59,31 @@ impl PersistentData {
} }
pub(crate) fn force_document_order(&mut self, order: Vec<DocumentId>) { pub(crate) fn force_document_order(&mut self, order: Vec<DocumentId>) {
let mut ordered_prefix_length = 0;
for id in &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;
}
}
self.document_order = Some(order); self.document_order = Some(order);
self.documents.force_order(self.document_order.as_ref().unwrap());
self.flush(); self.flush();
} }
fn read_document(&self, id: &DocumentId) -> Option<Document> {
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,
})
}
fn flush(&self) { fn flush(&self) {
let data = match ron::ser::to_string_pretty(self, Default::default()) { let data = match ron::ser::to_string_pretty(self, Default::default()) {
Ok(d) => d, Ok(d) => d,
@ -107,6 +118,31 @@ impl PersistentData {
} }
}; };
*self = loaded; *self = loaded;
self.garbage_collect_document_files();
}
fn garbage_collect_document_files(&self) {
let valid_paths: std::collections::HashSet<_> = self.documents.iter().map(|doc| Self::document_content_path(&doc.id)).collect();
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;
}
};
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}");
}
}
}
} }
fn state_file_path() -> std::path::PathBuf { fn state_file_path() -> std::path::PathBuf {
@ -114,79 +150,10 @@ impl PersistentData {
path.push(crate::consts::APP_STATE_FILE_NAME); path.push(crate::consts::APP_STATE_FILE_NAME);
path path
} }
}
#[derive(Default, serde::Serialize, serde::Deserialize)] fn document_content_path(id: &DocumentId) -> std::path::PathBuf {
struct DocumentStore(Vec<DocumentInfo>);
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}");
}
}
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<Document> {
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,
})
}
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);
}
ordered_prefix_len += 1;
}
}
}
fn document_ids(&self) -> Vec<DocumentId> {
self.0.iter().map(|meta| meta.id).collect()
}
fn document_path(id: &DocumentId) -> std::path::PathBuf {
let mut path = crate::dirs::app_autosave_documents_dir(); let mut path = crate::dirs::app_autosave_documents_dir();
path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION)); path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION));
path path
} }
} }
#[derive(serde::Serialize, serde::Deserialize)]
struct DocumentInfo {
id: DocumentId,
name: String,
path: Option<std::path::PathBuf>,
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,
}
}
}

View File

@ -71,9 +71,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument { dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument {
id: document_id, id: document_id,
document: Document { document: Document {
content: document,
name: details.name, name: details.name,
path: details.path, path: details.path,
content: document,
is_saved: details.is_saved, is_saved: details.is_saved,
}, },
}); });
@ -95,11 +95,8 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
// Forward this to update the UI // Forward this to update the UI
return Some(FrontendMessage::UpdateOpenDocumentsList { open_documents }); return Some(FrontendMessage::UpdateOpenDocumentsList { open_documents });
} }
FrontendMessage::TriggerLoadFirstAutoSaveDocument => { FrontendMessage::TriggerLoadAutoSaveDocuments => {
dispatcher.respond(DesktopFrontendMessage::PersistenceLoadCurrentDocument); dispatcher.respond(DesktopFrontendMessage::PersistenceLoadDocuments);
}
FrontendMessage::TriggerLoadRestAutoSaveDocuments => {
dispatcher.respond(DesktopFrontendMessage::PersistenceLoadRemainingDocuments);
} }
FrontendMessage::TriggerOpenLaunchDocuments => { FrontendMessage::TriggerOpenLaunchDocuments => {
dispatcher.respond(DesktopFrontendMessage::OpenLaunchDocuments); dispatcher.respond(DesktopFrontendMessage::OpenLaunchDocuments);

View File

@ -3,6 +3,7 @@ use std::path::PathBuf;
pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage; 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_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::input_mapper::utility_types::input_mouse::{EditorMouseState as MouseState, EditorPosition as Position, MouseKeys};
pub use graphite_editor::messages::prelude::DocumentId; pub use graphite_editor::messages::prelude::DocumentId;
@ -49,8 +50,7 @@ pub enum DesktopFrontendMessage {
PersistenceUpdateCurrentDocument { PersistenceUpdateCurrentDocument {
id: DocumentId, id: DocumentId,
}, },
PersistenceLoadCurrentDocument, PersistenceLoadDocuments,
PersistenceLoadRemainingDocuments,
PersistenceUpdateDocumentsList { PersistenceUpdateDocumentsList {
ids: Vec<DocumentId>, ids: Vec<DocumentId>,
}, },

View File

@ -123,8 +123,7 @@ pub enum FrontendMessage {
document: String, document: String,
details: DocumentDetails, details: DocumentDetails,
}, },
TriggerLoadFirstAutoSaveDocument, TriggerLoadAutoSaveDocuments,
TriggerLoadRestAutoSaveDocuments,
TriggerOpenLaunchDocuments, TriggerOpenLaunchDocuments,
TriggerLoadPreferences, TriggerLoadPreferences,
TriggerLoadWorkspaceLayout, TriggerLoadWorkspaceLayout,

View File

@ -15,12 +15,29 @@ pub struct OpenDocument {
pub struct DocumentDetails { pub struct DocumentDetails {
pub name: String, pub name: String,
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
#[serde(rename = "isSaved")] #[serde(alias = "isSaved")]
pub is_saved: bool, pub is_saved: bool,
#[serde(rename = "isAutoSaved")] #[serde(alias = "isAutoSaved")]
pub is_auto_saved: bool, 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<PathBuf>,
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<PersistedDocumentInfo>,
pub current_document: Option<DocumentId>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum MouseCursorIcon { pub enum MouseCursorIcon {

View File

@ -109,8 +109,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
// Before loading any documents, initially prepare the welcome screen buttons layout // Before loading any documents, initially prepare the welcome screen buttons layout
responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout); responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout);
// Tell frontend to load the current document // Tell frontend to load persistent auto-saved documents (placed early so IndexedDB reads overlap with subsequent UI setup)
responses.add(FrontendMessage::TriggerLoadFirstAutoSaveDocument); 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 // Display the menu bar at the top of the window
responses.add(MenuBarMessage::SendLayout); responses.add(MenuBarMessage::SendLayout);
@ -118,11 +121,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
// Send the initial workspace panel layout to the frontend // Send the initial workspace panel layout to the frontend
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout); responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
// Send the information for tooltips and categories for each node/input. // Request status bar info layout
responses.add(FrontendMessage::SendUIMetadata { responses.add(PortfolioMessage::RequestStatusBarInfoLayout);
node_descriptions: document_node_definitions::collect_node_descriptions(),
node_types: document_node_definitions::collect_node_types(),
});
// Send shortcuts for widgets created in the frontend which need shortcut tooltips // Send shortcuts for widgets created in the frontend which need shortcut tooltips
responses.add(FrontendMessage::SendShortcutFullscreen { responses.add(FrontendMessage::SendShortcutFullscreen {
@ -136,14 +136,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
shortcut: action_shortcut_manual!(Key::Shift, Key::MouseLeft), shortcut: action_shortcut_manual!(Key::Shift, Key::MouseLeft),
}); });
// Request status bar info layout // Send the information for tooltips and categories for each node/input.
responses.add(PortfolioMessage::RequestStatusBarInfoLayout); responses.add(FrontendMessage::SendUIMetadata {
node_descriptions: document_node_definitions::collect_node_descriptions(),
// Tell frontend to finish loading persistent documents node_types: document_node_definitions::collect_node_types(),
responses.add(FrontendMessage::TriggerLoadRestAutoSaveDocuments); });
// Tell frontend to load documented passed in as launch arguments
responses.add(FrontendMessage::TriggerOpenLaunchDocuments);
} }
PortfolioMessage::DocumentPassMessage { document_id, message } => { PortfolioMessage::DocumentPassMessage { document_id, message } => {
if let Some(document) = self.documents.get_mut(&document_id) { if let Some(document) = self.documents.get_mut(&document_id) {

View File

@ -33,7 +33,7 @@
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : []; $: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => { $: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
const name = doc.details.name; const name = doc.details.name;
const unsaved = !doc.details.isSaved; const unsaved = !doc.details.is_saved;
if (!editor.inDevelopmentMode()) return { name, unsaved }; if (!editor.inDevelopmentMode()) return { name, unsaved };
const tooltipDescription = `Document ID: ${doc.id}`; const tooltipDescription = `Document ID: ${doc.id}`;

View File

@ -7,8 +7,7 @@ import {
loadWorkspaceLayout, loadWorkspaceLayout,
storeDocument, storeDocument,
removeDocument, removeDocument,
loadFirstDocument, loadDocuments,
loadRestDocuments,
saveActiveDocument, saveActiveDocument,
} from "/src/utility-functions/persistence"; } from "/src/utility-functions/persistence";
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; 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); await removeDocument(String(data.documentId), portfolio);
}); });
subscriptions.subscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument", async () => { subscriptions.subscribeFrontendMessage("TriggerLoadAutoSaveDocuments", async () => {
await loadFirstDocument(editor); await loadDocuments(editor);
});
subscriptions.subscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments", async () => {
await loadRestDocuments(editor);
}); });
subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => { subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
@ -75,8 +70,7 @@ export function destroyPersistenceManager() {
subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout"); subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument"); subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerLoadAutoSaveDocuments");
subscriptions.unsubscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments");
subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments"); subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments");
subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument");
} }

View File

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

View File

@ -1,155 +1,148 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { PortfolioStore } from "/src/stores/portfolio"; import type { PortfolioStore } from "/src/stores/portfolio";
import type { MessageBody } from "/src/subscriptions-router"; 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_DB = "graphite";
const PERSISTENCE_STORE = "store"; const PERSISTENCE_STORE = "store";
export async function storeDocumentTabOrder(portfolio: PortfolioStore) { function emptyPersistedState(): PersistedState {
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id)); // eslint-disable-next-line camelcase
await databaseSet("documents_tab_order", documentOrder); return { documents: [], current_document: undefined };
} }
export async function storeCurrentDocumentId(documentId: string) { function createDocumentInfo(id: bigint, name: string, isSaved: boolean): PersistedDocumentInfo {
await databaseSet("current_document_id", String(documentId)); // 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;
}
// ====================================
// 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) { export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
await databaseUpdate<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", (old) => { const { documentId, document, details } = autoSaveDocument;
// Update content in the documents store
await databaseUpdate<Record<string, string>>("documents", (old) => {
const documents = old || {}; const documents = old || {};
documents[String(autoSaveDocument.documentId)] = autoSaveDocument; documents[String(documentId)] = document;
return documents; return documents;
}); });
await storeDocumentTabOrder(portfolio); // Update metadata and ordering in the state store
await storeCurrentDocumentId(String(autoSaveDocument.documentId)); 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) { export async function removeDocument(id: string, portfolio: PortfolioStore) {
await databaseUpdate<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", (old) => { const documentId = BigInt(id);
// Remove content from the documents store
await databaseUpdate<Record<string, string>>("documents", (old) => {
const documents = old || {}; const documents = old || {};
delete documents[id]; delete documents[id];
return documents; return documents;
}); });
await databaseUpdate<string[]>("documents_tab_order", (old) => { // Update state: remove the entry and update current_document
const order = old || []; const portfolioData = get(portfolio);
return order.filter((docId) => docId !== id); 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) {
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);
}); });
const documentCount = get(portfolio).documents.length; editor.selectDocument(current.id);
if (documentCount > 0) {
const documentIndex = get(portfolio).activeDocumentIndex;
const documentId = String(get(portfolio).documents[documentIndex].id);
const tabOrder = (await databaseGet<string[]>("documents_tab_order")) || [];
if (tabOrder.includes(documentId)) {
await storeCurrentDocumentId(documentId);
}
} else {
await databaseDelete("current_document_id");
}
}
export async function loadFirstDocument(editor: EditorWrapper) {
const previouslySavedDocuments = await databaseGet<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("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<string[]>("documents_tab_order");
const currentDocumentIdString = await databaseGet<string>("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);
}
}
}
export async function loadRestDocuments(editor: EditorWrapper) {
const previouslySavedDocuments = await databaseGet<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("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<string[]>("documents_tab_order");
const currentDocumentIdString = await databaseGet<string>("current_document_id");
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
if (!previouslySavedDocuments || !documentOrder) return;
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
const currentIndex = currentDocumentId !== undefined ? orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId) : -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);
}
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);
}
if (length > 0) editor.selectDocument(orderedSavedDocuments[length - 1].documentId);
}
} }
export async function saveActiveDocument(documentId: bigint) { export async function saveActiveDocument(documentId: bigint) {
const previouslySavedDocuments = await databaseGet<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents"); await databaseUpdate<PersistedState>("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 return state;
// 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);
}
} }
export async function saveEditorPreferences(preferences: unknown) { export async function saveEditorPreferences(preferences: unknown) {
@ -170,12 +163,118 @@ export async function loadWorkspaceLayout(editor: EditorWrapper) {
if (layout) editor.loadWorkspaceLayout(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");
const documentContents = await databaseGet<Record<string, string>>("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() { 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("documents_tab_order");
await databaseDelete("current_document_id"); 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<string[]>("documents_tab_order");
if (oldTabOrder === undefined) return;
const oldDocuments = await databaseGet<Record<string, unknown>>("documents");
// Build the new "state" and "documents" from the old format
const newDocumentContents: Record<string, string> = {};
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<IDBDatabase> { function databaseOpen(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(PERSISTENCE_DB, 1); const request = indexedDB.open(PERSISTENCE_DB, 1);