diff --git a/Cargo.lock b/Cargo.lock index 531cc58d3d..c62af86653 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2160,6 +2160,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-executor", + "zip", ] [[package]] @@ -5901,6 +5902,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -7599,6 +7606,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 3e3fdddb43..ec98ff71a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -219,6 +219,7 @@ lzma-rust2 = { version = "0.16", default-features = false, features = ["std", "e scraper = "0.25" linesweeper = "0.3" smallvec = "1.13.2" +zip = { version = "8", default-features = false } [workspace.lints.rust] unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 63d7d13495..ee2c453404 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -249,6 +249,14 @@ impl App { }); } DesktopFrontendMessage::WriteFile { path, content } => { + // Create the parent directory on demand so flows that drop multiple files into a fresh folder + // (e.g. recovered-documents bundle) don't have to materialize it ahead of time + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + && let Err(e) = fs::create_dir_all(parent) + { + tracing::error!("Failed to create parent directory {}: {}", parent.display(), e); + } if let Err(e) = fs::write(&path, content) { tracing::error!("Failed to write file {}: {}", path.display(), e); } diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 1062d77ad9..0ada72e140 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -31,6 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess SaveFileDialogContext::File { content } => { dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content }); } + SaveFileDialogContext::RecoveredDocuments { files } => { + // Strip any extension the user might have typed (e.g. picking `MyRecovery.zip` yields `MyRecovery/`). + // `WriteFile`'s handler creates parent directories on demand, so the folder is materialized lazily. + let folder = path.with_extension(""); + for (filename, content) in files { + let file_path = folder.join(&filename); + dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content }); + } + } }, DesktopWrapperMessage::OpenFile { path, content } => { let message = PortfolioMessage::OpenFile { path, content }; diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 2504746f8a..2556a33657 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -59,6 +59,18 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD context: SaveFileDialogContext::File { content }, }); } + FrontendMessage::TriggerSaveRecoveredDocumentsFolder { folder_name, files } => { + // Treat the save dialog's chosen path as a folder name. The dialog result handler strips any + // extension the user typed and writes each entry inside that folder + let files: Vec<(String, Vec)> = files.into_iter().map(|(name, bytes)| (name, bytes.into_vec())).collect(); + dispatcher.respond(DesktopFrontendMessage::SaveFileDialog { + title: "Save Recovered Documents Folder As".to_string(), + default_filename: folder_name, + default_folder: None, + filters: Vec::new(), + context: SaveFileDialogContext::RecoveredDocuments { files }, + }); + } FrontendMessage::TriggerVisitLink { url } => { dispatcher.respond(DesktopFrontendMessage::OpenUrl(url)); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index a571eb49ac..cbdeb6d1db 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -111,8 +111,19 @@ pub enum OpenFileDialogContext { } pub enum SaveFileDialogContext { - Document { document_id: DocumentId, content: Vec }, - File { content: Vec }, + Document { + document_id: DocumentId, + content: Vec, + }, + File { + content: Vec, + }, + /// Bundle of recovered (failed-to-deserialize) autosaved documents to be written into a single folder. + /// The chosen `path` returned by the save dialog has its extension stripped to become the folder name. + /// The parent directories are created on first write. + RecoveredDocuments { + files: Vec<(String, Vec)>, + }, } pub enum MenuItem { diff --git a/editor/Cargo.toml b/editor/Cargo.toml index fb83dcd832..1dfd7eac65 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -47,6 +47,7 @@ base64 = { workspace = true } spin = { workspace = true } image = { workspace = true } color = { workspace = true } +zip = { workspace = true } # Optional local dependencies wgpu-executor = { workspace = true, optional = true } diff --git a/editor/src/messages/dialog/simple_dialogs/failed_to_load_documents_dialog.rs b/editor/src/messages/dialog/simple_dialogs/failed_to_load_documents_dialog.rs new file mode 100644 index 0000000000..3a46297397 --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/failed_to_load_documents_dialog.rs @@ -0,0 +1,115 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +/// A dialog shown after startup when one or more autosaved documents fail to deserialize. +/// Offers the user a chance to download the raw content (so the data isn't lost), discard the failed +/// documents, or dismiss the dialog (keeping the autosave for a later session, when the deserialization +/// bug may be fixed in a future release). +pub struct FailedToLoadDocumentsDialog { + pub failed_document_names: Vec, +} + +impl DialogLayoutHolder for FailedToLoadDocumentsDialog { + const ICON: &'static str = "Warning"; + const TITLE: &'static str = "Failed to Open Documents"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![ + TextButton::new("Download") + .emphasized(true) + .tooltip_description("Save the raw document data to disk so it can be recovered later.") + .on_update(|_| { + DialogMessage::CloseAndThen { + followups: vec![PortfolioMessage::DownloadFailedToLoadDocuments.into()], + } + .into() + }) + .widget_instance(), + TextButton::new("Discard") + .tooltip_description("Permanently delete the autosaved data for these documents.") + .on_update(|_| { + DialogMessage::CloseAndThen { + followups: vec![PortfolioMessage::DiscardFailedToLoadDocuments.into()], + } + .into() + }) + .widget_instance(), + TextButton::new("Dismiss") + .tooltip_description("Close this dialog. The autosaved data is kept and this dialog will reappear on next launch.") + .on_update(|_| FrontendMessage::DialogClose.into()) + .widget_instance(), + ]; + + Layout(vec![LayoutGroup::row(widgets)]) + } +} + +impl LayoutHolder for FailedToLoadDocumentsDialog { + fn layout(&self) -> Layout { + let count = self.failed_document_names.len(); + let header = format!("{count} document{} couldn't be reopened.", if count == 1 { "" } else { "s" }); + let list = "• ".to_string() + &self.failed_document_names.join("\n• "); + let plural_s = if count == 1 { "" } else { "s" }; + let plural_it_them = if count == 1 { "it" } else { "them" }; + + Layout(vec![ + LayoutGroup::row(vec![TextLabel::new(header).bold(true).multiline(true).widget_instance()]), + LayoutGroup::row(vec![ + TextLabel::new(format!( + "Sorry about that!\n\ + This shouldn't happen, and we'd like to help.\n\ + \n\ + Click \"Download\" to save a copy of the affected file{plural_s},\n\ + then please share {plural_it_them} with us so we can investigate:" + )) + .multiline(true) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextButton::new("Ask on Discord") + .icon("Volunteer") + .flush(true) + .on_update(|_| { + FrontendMessage::TriggerVisitLink { + url: "https://discord.graphite.art".into(), + } + .into() + }) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextButton::new("Report on GitHub") + .icon("Bug") + .flush(true) + .on_update(|_| { + FrontendMessage::TriggerVisitLink { + url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(), + } + .into() + }) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextLabel::new( + "In the meantime, you can keep working in the\n\ + previous version of Graphite:", + ) + .multiline(true) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextButton::new("Sept. 2025 Release") + .icon("GraphiteLogo") + .flush(true) + .on_update(|_| { + FrontendMessage::TriggerVisitLink { + url: "https://57130155.graphite.pages.dev/".into(), + } + .into() + }) + .widget_instance(), + ]), + LayoutGroup::row(vec![TextLabel::new(format!("Affected document{plural_s}:\n{list}")).multiline(true).widget_instance()]), + ]) + } +} diff --git a/editor/src/messages/dialog/simple_dialogs/failed_to_open_document_dialog.rs b/editor/src/messages/dialog/simple_dialogs/failed_to_open_document_dialog.rs new file mode 100644 index 0000000000..7835b38490 --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/failed_to_open_document_dialog.rs @@ -0,0 +1,90 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +/// A dialog shown when a manually opened document fails to deserialize. Mirrors the recovery affordances +/// of [`super::FailedToLoadDocumentsDialog`] (offering Discord/GitHub/previous-version links) but for +/// the single-file, user-initiated open case, where the file is already on the user's disk and there's +/// nothing to download or discard. +pub struct FailedToOpenDocumentDialog { + /// Display name of the file the user tried to open; used in the dialog header. Falls back to a + /// generic phrase when empty. + pub document_name: String, +} + +impl DialogLayoutHolder for FailedToOpenDocumentDialog { + const ICON: &'static str = "Warning"; + const TITLE: &'static str = "Failed to Open Document"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()]; + Layout(vec![LayoutGroup::row(widgets)]) + } +} + +impl LayoutHolder for FailedToOpenDocumentDialog { + fn layout(&self) -> Layout { + let header = if self.document_name.trim().is_empty() { + "The document couldn't be opened.".to_string() + } else { + format!("\"{}\" couldn't be opened.", self.document_name) + }; + + Layout(vec![ + LayoutGroup::row(vec![TextLabel::new(header).bold(true).multiline(true).widget_instance()]), + LayoutGroup::row(vec![ + TextLabel::new( + "Sorry about that!\n\ + This shouldn't happen, and we'd like to help.\n\ + \n\ + Please share the file with us so we can investigate:", + ) + .multiline(true) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextButton::new("Ask on Discord") + .icon("Volunteer") + .flush(true) + .on_update(|_| { + FrontendMessage::TriggerVisitLink { + url: "https://discord.graphite.art".into(), + } + .into() + }) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextButton::new("Report on GitHub") + .icon("Bug") + .flush(true) + .on_update(|_| { + FrontendMessage::TriggerVisitLink { + url: "https://github.com/GraphiteEditor/Graphite/issues/new".into(), + } + .into() + }) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextLabel::new( + "In the meantime, you can keep working in the\n\ + previous version of Graphite:", + ) + .multiline(true) + .widget_instance(), + ]), + LayoutGroup::row(vec![ + TextButton::new("Sept. 2025 Release") + .icon("GraphiteLogo") + .flush(true) + .on_update(|_| { + FrontendMessage::TriggerVisitLink { + url: "https://57130155.graphite.pages.dev/".into(), + } + .into() + }) + .widget_instance(), + ]), + ]) + } +} diff --git a/editor/src/messages/dialog/simple_dialogs/mod.rs b/editor/src/messages/dialog/simple_dialogs/mod.rs index f775faec62..aa2e8103c3 100644 --- a/editor/src/messages/dialog/simple_dialogs/mod.rs +++ b/editor/src/messages/dialog/simple_dialogs/mod.rs @@ -4,6 +4,8 @@ mod close_document_dialog; mod confirm_restart_dialog; mod demo_artwork_dialog; mod error_dialog; +mod failed_to_load_documents_dialog; +mod failed_to_open_document_dialog; mod licenses_dialog; mod licenses_third_party_dialog; @@ -14,5 +16,7 @@ pub use confirm_restart_dialog::ConfirmRestartDialog; pub use demo_artwork_dialog::ARTWORK; pub use demo_artwork_dialog::DemoArtworkDialog; pub use error_dialog::ErrorDialog; +pub use failed_to_load_documents_dialog::FailedToLoadDocumentsDialog; +pub use failed_to_open_document_dialog::FailedToOpenDocumentDialog; pub use licenses_dialog::LicensesDialog; pub use licenses_third_party_dialog::LicensesThirdPartyDialog; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index c7b4489fbb..6cb2b6aaed 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -101,6 +101,16 @@ pub enum FrontendMessage { folder: Option, content: serde_bytes::ByteBuf, }, + /// Save a bundle of recovered (failed-to-deserialize) autosaved documents to disk on desktop. + /// The desktop wrapper intercepts this, prompts the user for a location via the native save + /// dialog, and writes each entry as a separate file into a folder named after `folderName`. + /// The web build bundles them as a single `.zip` and delivers it through `TriggerSaveFile`, + /// so this message is only ever dispatched on desktop and never reaches the web frontend. + TriggerSaveRecoveredDocumentsFolder { + #[serde(rename = "folderName")] + folder_name: String, + files: Vec<(String, serde_bytes::ByteBuf)>, + }, TriggerExportImage { svg: String, name: String, diff --git a/editor/src/messages/portfolio/document/utility_types/error.rs b/editor/src/messages/portfolio/document/utility_types/error.rs index 5080f46381..ca223e6188 100644 --- a/editor/src/messages/portfolio/document/utility_types/error.rs +++ b/editor/src/messages/portfolio/document/utility_types/error.rs @@ -16,13 +16,9 @@ pub enum EditorError { #[error("The operation caused a document error:\n{0:?}")] Document(String), - #[error( - "This document was created in an older version of the editor.\n\ - \n\ - Full backwards compatibility is not guaranteed in the current alpha release.\n\ - \n\ - If this document is critical, ask for support in Graphite's Discord community." - )] + // User-facing presentation now lives in `FailedToOpenDocumentDialog` / + // `FailedToLoadDocumentsDialog`; this Display string is for logs only. + #[error("Failed to deserialize document: {0}")] DocumentDeserialization(String), #[error("{0}")] diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index e188278989..7b89d81edb 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -71,6 +71,9 @@ pub enum PortfolioMessage { document_id: DocumentId, document_serialized_content: String, }, + ShowFailedToLoadDocumentsDialog, + DiscardFailedToLoadDocuments, + DownloadFailedToLoadDocuments, MoveAllPanelTabs { source_group: PanelGroupId, target_group: PanelGroupId, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 8dd03294ec..735f307173 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -52,6 +52,18 @@ pub struct PortfolioMessageContext<'a> { pub struct PortfolioMessageHandler { pub documents: HashMap, unloaded_documents: HashMap, + /// Autosaved documents that could not be deserialized. The DocumentInfo identifies them in the + /// persisted state (so their autosave file is not garbage-collected) and the String holds the raw + /// serialized content that can be downloaded by the user as a recovery action. + failed_to_load_documents: HashMap, + /// Number of autosaved-document loads still in-flight from the initial startup batch (includes + /// both the active doc and the background eager loads). Decremented on each completion; + /// when it reaches 0 while `failed_to_load_documents` is non-empty, the batched dialog is shown. + pending_initial_autosave_loads: usize, + /// Doc IDs being eagerly loaded in the background (not the user-selected active doc). When such + /// a load completes, `LoadDocumentContent` skips its trailing `SelectDocument` so that focus + /// doesn't bounce around as background loads finish. + pending_eager_loads: HashSet, document_ids: VecDeque, pub(crate) active_document_id: Option, persistent_state: PersistentStateMessageHandler, @@ -468,11 +480,13 @@ impl MessageHandler> for Portfolio workspace_layout: _, } = state; + let mut newly_unloaded_ids = Vec::new(); for info in documents { if !self.document_ids.contains(&info.id) { self.document_ids.push_back(info.id); } - if !self.documents.contains_key(&info.id) { + if !self.documents.contains_key(&info.id) && !self.unloaded_documents.contains_key(&info.id) { + newly_unloaded_ids.push(info.id); self.unloaded_documents.insert(info.id, info); } } @@ -480,8 +494,24 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::UpdateOpenDocumentsList); let select_document_id = current_document.filter(|id| self.document_ids.contains(id)).or_else(|| self.document_ids.front().copied()); + + // Eagerly request the content of every autosaved document so any deserialization failures are + // detected up front and can be reported together as a single batched dialog at the end. + // The active document's `ReadDocument` is left for `SelectDocument` (issued below) to dispatch, + // to avoid a duplicate read, but it's still counted in `pending_initial_autosave_loads`. + self.pending_initial_autosave_loads = self.pending_initial_autosave_loads.saturating_add(newly_unloaded_ids.len()); + for document_id in &newly_unloaded_ids { + if Some(*document_id) != select_document_id { + self.pending_eager_loads.insert(*document_id); + responses.add(PersistentStateMessage::ReadDocument { document_id: *document_id }); + } + } + if let Some(document_id) = select_document_id { responses.add(PortfolioMessage::SelectDocument { document_id }); + } else if self.pending_initial_autosave_loads == 0 && !self.failed_to_load_documents.is_empty() { + // No active document to trigger the final dialog via load completion (show it now) + responses.add(PortfolioMessage::ShowFailedToLoadDocumentsDialog); } } PortfolioMessage::LoadDocumentContent { @@ -501,7 +531,100 @@ impl MessageHandler> for Portfolio document_is_saved: info.is_saved, document_serialized_content, }); - responses.add(PortfolioMessage::SelectDocument { document_id }); + // Skip the auto-select for background eager loads kicked off at startup so focus stays on the + // user's intended active document while the others stream in. + if !self.pending_eager_loads.remove(&document_id) { + responses.add(PortfolioMessage::SelectDocument { document_id }); + } + } + PortfolioMessage::ShowFailedToLoadDocumentsDialog => { + if self.failed_to_load_documents.is_empty() { + return; + } + let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| info.name.clone()).collect(); + let dialog = simple_dialogs::FailedToLoadDocumentsDialog { failed_document_names }; + dialog.send_dialog_to_frontend(responses); + } + PortfolioMessage::DiscardFailedToLoadDocuments => { + let failed = std::mem::take(&mut self.failed_to_load_documents); + for document_id in failed.keys() { + // Remove from the tab list (we kept it there so the autosave file wasn't garbage-collected) and + // then ask the persistence layer to delete its on-disk content. + self.document_ids.retain(|id| id != document_id); + responses.add(PersistentStateMessage::DeleteDocument { document_id: *document_id }); + } + responses.add(PortfolioMessage::UpdateOpenDocumentsList); + responses.add(PersistentStateMessage::WriteState); + } + PortfolioMessage::DownloadFailedToLoadDocuments => { + if self.failed_to_load_documents.is_empty() { + return; + } + + // Build deduplicated `(filename, bytes)` entries shared by both targets. + let mut used_names: HashMap = HashMap::new(); + let files: Vec<(String, Vec)> = self + .failed_to_load_documents + .values() + .map(|(info, content)| { + let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.clone() }; + let base = format!("{stem}.{FILE_EXTENSION}"); + let unique = match used_names.get(&base).copied() { + None => { + used_names.insert(base.clone(), 1); + base + } + Some(n) => { + used_names.insert(base.clone(), n + 1); + format!("{stem} ({n}).{FILE_EXTENSION}") + } + }; + (unique, content.as_bytes().to_vec()) + }) + .collect(); + + const FOLDER_NAME: &str = "Graphite Recovered Documents"; + + // Web: build the archive here (in Rust) and deliver it via the existing single-file + // `TriggerSaveFile` plumbing. Web APIs can't deliver a multi-file save. + #[cfg(target_family = "wasm")] + { + if files.len() == 1 { + let (filename, content) = files.into_iter().next().expect("just checked there's one entry"); + responses.add(FrontendMessage::TriggerSaveFile { + name: filename, + folder: None, + content: serde_bytes::ByteBuf::from(content), + }); + } else { + match build_recovery_zip(&files) { + Ok(zip_bytes) => responses.add(FrontendMessage::TriggerSaveFile { + name: format!("{FOLDER_NAME}.zip"), + folder: None, + content: serde_bytes::ByteBuf::from(zip_bytes), + }), + Err(e) => { + log::error!("Failed to build recovery zip: {e}"); + responses.add(DialogMessage::DisplayDialogError { + title: "Failed to download".to_string(), + description: format!("Could not bundle the failed documents for download.\n\n{e}"), + }); + } + } + } + } + + // Desktop: the wrapper intercepts this and writes each file into a user-chosen folder + // (the native file picker can't return a multi-file destination directly, so the + // chosen path is used as the folder name). + #[cfg(not(target_family = "wasm"))] + { + let files = files.into_iter().map(|(name, bytes)| (name, serde_bytes::ByteBuf::from(bytes))).collect(); + responses.add(FrontendMessage::TriggerSaveRecoveredDocumentsFolder { + folder_name: FOLDER_NAME.to_string(), + files, + }); + } } PortfolioMessage::NewDocumentWithName { name } => { let mut new_document = DocumentMessageHandler::default(); @@ -744,11 +867,43 @@ impl MessageHandler> for Portfolio let mut document = match document { Ok(document) => document, Err(e) => { - if !document_is_auto_saved { - responses.add(DialogMessage::DisplayDialogError { - title: "Failed to open document".to_string(), - description: e.to_string(), - }); + if document_is_auto_saved { + // Accumulate this failure to report alongside any others via a single batched dialog. + // Hold onto the raw serialized content so the user can download it (and so the file + // remains in `state.documents` via `persisted_state_snapshot` and isn't garbage-collected + // from the autosave directory). Remove the failed doc from `document_ids` so it doesn't + // appear as a broken tab. The persisted state still references it via the snapshot's + // added entries for `failed_to_load_documents`. + let name = document_name.unwrap_or_default(); + let info = DocumentInfo { + id: document_id, + name, + path: document_path, + is_saved: document_is_saved, + }; + self.document_ids.retain(|id| id != &document_id); + self.failed_to_load_documents.insert(document_id, (info, document_serialized_content)); + + // If this was the doc the user was trying to focus, fall back to whatever's still openable. + if self.active_document_id == Some(document_id) { + self.active_document_id = None; + if let Some(next_id) = self.document_ids.front().copied() { + responses.add(PortfolioMessage::SelectDocument { document_id: next_id }); + } + } + + responses.add(PortfolioMessage::UpdateOpenDocumentsList); + self.tick_autosave_load_progress(responses, true); + } else { + log::error!("{e}"); + // Prefer the explicit `document_name`; otherwise derive a display name from the file + // path's stem so the dialog header isn't generic when we have a usable label. + let name = document_name + .filter(|n| !n.trim().is_empty()) + .or_else(|| document_path.as_ref().and_then(|p| p.file_stem()).map(|s| s.to_string_lossy().into_owned())) + .unwrap_or_default(); + let dialog = simple_dialogs::FailedToOpenDocumentDialog { document_name: name }; + dialog.send_dialog_to_frontend(responses); } return; @@ -821,6 +976,10 @@ impl MessageHandler> for Portfolio self.load_document(document, document_id, responses); responses.add(AppWindowMessage::Focus); + + if document_is_auto_saved { + self.tick_autosave_load_progress(responses, false); + } } PortfolioMessage::OpenImage { name, image } => { // `NewDocumentWithName`'s handler routes empty/None-equivalent names through `resolve_document_name` which assigns the next available "Untitled Document {N}". @@ -1730,7 +1889,13 @@ impl PortfolioMessageHandler { } pub fn persisted_state_snapshot(&self) -> PersistedState { - let documents = self.document_ids.iter().filter_map(|id| self.document_details(*id)).collect::>(); + let mut documents = self.document_ids.iter().filter_map(|id| self.document_details(*id)).collect::>(); + // Also persist entries for failed-to-load documents so their autosave files survive the next + // `garbage_collect_document_files` pass (which deletes anything not in `state.documents`), + // but we don't include them in `document_ids`, so they don't appear as broken tabs in the UI. + for (info, _) in self.failed_to_load_documents.values() { + documents.push(info.clone()); + } PersistedState { documents, @@ -1774,6 +1939,21 @@ impl PortfolioMessageHandler { } } + /// Decrement the count of in-flight initial autosave loads and, if this completion finishes + /// the initial startup batch with at least one failure, dispatch the batched dialog. + /// Lazy-load failures (those arriving after the initial batch finishes) also dispatch the + /// dialog so the user gets a chance to recover the data. + fn tick_autosave_load_progress(&mut self, responses: &mut VecDeque, failed: bool) { + if self.pending_initial_autosave_loads > 0 { + self.pending_initial_autosave_loads -= 1; + if self.pending_initial_autosave_loads == 0 && !self.failed_to_load_documents.is_empty() { + responses.add(PortfolioMessage::ShowFailedToLoadDocumentsDialog); + } + } else if failed { + responses.add(PortfolioMessage::ShowFailedToLoadDocumentsDialog); + } + } + fn read_file(path: &PathBuf, content: Vec) -> FileContent { let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default().to_lowercase(); match extension.as_str() { @@ -1982,3 +2162,28 @@ impl PortfolioMessageHandler { } } } + +/// Bundle the given `(filename, bytes)` entries into a single uncompressed (Stored) zip archive. +/// Web-only: web APIs can't deliver a multi-file save, so the recovered-documents flow packs +/// everything here and ships it through `TriggerSaveFile`. On desktop the wrapper writes the same +/// entries to a user-chosen folder instead, so this helper isn't used. +#[cfg(target_family = "wasm")] +fn build_recovery_zip(entries: &[(String, Vec)]) -> Result, String> { + use std::io::{Cursor, Write}; + use zip::write::{SimpleFileOptions, ZipWriter}; + + let mut buffer = Cursor::new(Vec::::new()); + let mut writer = ZipWriter::new(&mut buffer); + + // Store mode (no compression) keeps the dependency surface small (no compression backends needed) + // and is fine for these payloads: recovery downloads are rare and the user can re-compress later. + let options: SimpleFileOptions = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).unix_permissions(0o644); + + for (filename, content) in entries { + writer.start_file(filename, options).map_err(|e| format!("start_file: {e}"))?; + writer.write_all(content).map_err(|e| format!("write_all: {e}"))?; + } + + writer.finish().map_err(|e| format!("finish: {e}"))?; + Ok(buffer.into_inner()) +}