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:
parent
79d778a535
commit
60f16d72a5
|
|
@ -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.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 => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use crate::wrapper::messages::{Document, DocumentId};
|
||||
use crate::wrapper::messages::{Document, DocumentId, PersistedDocumentInfo};
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct PersistentData {
|
||||
documents: DocumentStore,
|
||||
documents: Vec<PersistedDocumentInfo>,
|
||||
current_document: Option<DocumentId>,
|
||||
#[serde(skip)]
|
||||
document_order: Option<Vec<DocumentId>>,
|
||||
|
|
@ -10,10 +10,22 @@ pub(crate) struct PersistentData {
|
|||
|
||||
impl PersistentData {
|
||||
pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) {
|
||||
self.documents.write(id, document);
|
||||
if let Some(order) = &self.document_order {
|
||||
self.documents.force_order(order);
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -21,45 +33,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<DocumentId> {
|
||||
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 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 +59,31 @@ impl PersistentData {
|
|||
}
|
||||
|
||||
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.documents.force_order(self.document_order.as_ref().unwrap());
|
||||
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) {
|
||||
let data = match ron::ser::to_string_pretty(self, Default::default()) {
|
||||
Ok(d) => d,
|
||||
|
|
@ -107,6 +118,31 @@ impl PersistentData {
|
|||
}
|
||||
};
|
||||
*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 {
|
||||
|
|
@ -114,79 +150,10 @@ impl PersistentData {
|
|||
path.push(crate::consts::APP_STATE_FILE_NAME);
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<DocumentId>,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -123,8 +123,7 @@ pub enum FrontendMessage {
|
|||
document: String,
|
||||
details: DocumentDetails,
|
||||
},
|
||||
TriggerLoadFirstAutoSaveDocument,
|
||||
TriggerLoadRestAutoSaveDocuments,
|
||||
TriggerLoadAutoSaveDocuments,
|
||||
TriggerOpenLaunchDocuments,
|
||||
TriggerLoadPreferences,
|
||||
TriggerLoadWorkspaceLayout,
|
||||
|
|
|
|||
|
|
@ -15,12 +15,29 @@ pub struct OpenDocument {
|
|||
pub struct DocumentDetails {
|
||||
pub name: String,
|
||||
pub path: Option<PathBuf>,
|
||||
#[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<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))]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum MouseCursorIcon {
|
||||
|
|
|
|||
|
|
@ -109,8 +109,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
export async function storeCurrentDocumentId(documentId: string) {
|
||||
await databaseSet("current_document_id", String(documentId));
|
||||
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;
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// 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) {
|
||||
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 || {};
|
||||
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<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) {
|
||||
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 || {};
|
||||
delete documents[id];
|
||||
return documents;
|
||||
});
|
||||
|
||||
await databaseUpdate<string[]>("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;
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
editor.selectDocument(current.id);
|
||||
}
|
||||
|
||||
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
|
||||
// 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<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() {
|
||||
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<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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(PERSISTENCE_DB, 1);
|
||||
|
|
|
|||
Loading…
Reference in New Issue