diff --git a/Cargo.lock b/Cargo.lock index 1e3e967f25..e465c40ff7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2192,6 +2192,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-executor", + "zip", ] [[package]] @@ -5957,6 +5958,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" @@ -7655,6 +7662,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 412ffcee74..a7e084372d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -221,6 +221,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/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..4181c8f9fd --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/failed_to_load_documents_dialog.rs @@ -0,0 +1,113 @@ +// TODO: Eventually remove this document upgrade code + +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +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..c6caeadcfd --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/failed_to_open_document_dialog.rs @@ -0,0 +1,84 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +pub struct FailedToOpenDocumentDialog { + 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/portfolio/document/utility_types/error.rs b/editor/src/messages/portfolio/document/utility_types/error.rs index 5080f46381..f1b5ad3ffc 100644 --- a/editor/src/messages/portfolio/document/utility_types/error.rs +++ b/editor/src/messages/portfolio/document/utility_types/error.rs @@ -16,13 +16,7 @@ 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." - )] + #[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 629c6332d5..2b42a972b0 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -72,6 +72,12 @@ pub enum PortfolioMessage { document_id: DocumentId, document_serialized_content: String, }, + // TODO: Eventually remove this document upgrade code + ShowFailedToLoadDocumentsDialog, + // TODO: Eventually remove this document upgrade code + DiscardFailedToLoadDocuments, + // TODO: Eventually remove this document upgrade code + 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 219c05d06c..4cb781a7bb 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -55,6 +55,16 @@ pub struct PortfolioMessageContext<'a> { pub struct PortfolioMessageHandler { pub documents: HashMap, unloaded_documents: HashMap, + /// Pairs of `(info, raw serialized content)` for autosaved documents that failed to deserialize. + /// The info entries are folded back into `persisted_state_snapshot` so their on-disk autosave files survive garbage collection. + // TODO: Eventually remove this document upgrade code + failed_to_load_documents: HashMap, + /// In-flight count of autosaved-document loads from the initial startup batch; the batched failure dialog fires when this hits 0. + // TODO: Eventually remove this document upgrade code + pending_initial_autosave_loads: usize, + /// Background eager loads whose trailing `SelectDocument` should be suppressed to keep focus on the user's active doc. + // TODO: Eventually remove this document upgrade code + pending_eager_loads: HashSet, document_ids: VecDeque, pub(crate) active_document_id: Option, persistent_state: PersistentStateMessageHandler, @@ -493,11 +503,17 @@ impl MessageHandler> for Portfolio workspace_layout: _, } = state; + // TODO: Eventually remove this document upgrade code + 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) { + // TODO: Eventually remove this document upgrade code + newly_unloaded_ids.push(info.id); + self.unloaded_documents.insert(info.id, info); } } @@ -505,9 +521,27 @@ 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 load every autosaved doc on startup so deserialization failures can be reported in one batched dialog at the end. + // The active doc's read is deferred to the `SelectDocument` below, but is still counted. + // TODO: Eventually remove this document upgrade code + self.pending_initial_autosave_loads = self.pending_initial_autosave_loads.saturating_add(newly_unloaded_ids.len()); + + // TODO: Eventually remove this document upgrade code + 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 }); } + // TODO: Eventually remove this document upgrade code + else if self.pending_initial_autosave_loads == 0 && !self.failed_to_load_documents.is_empty() { + responses.add(PortfolioMessage::ShowFailedToLoadDocumentsDialog); + } } PortfolioMessage::LoadDocumentContent { document_id, @@ -526,7 +560,85 @@ impl MessageHandler> for Portfolio document_is_saved: info.is_saved, document_serialized_content, }); - responses.add(PortfolioMessage::SelectDocument { document_id }); + + // Suppress auto-select for startup eager loads to keep focus on the user's active doc + // TODO: Eventually remove this document upgrade code + // TODO: (But keep the inner logic unconditionally, just remove the condition) + if !self.pending_eager_loads.remove(&document_id) { + responses.add(PortfolioMessage::SelectDocument { document_id }); + } + } + // TODO: Eventually remove this document upgrade code + PortfolioMessage::ShowFailedToLoadDocumentsDialog => { + if self.failed_to_load_documents.is_empty() { + return; + } + let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| display_name_with_fallback(info)).collect(); + let dialog = simple_dialogs::FailedToLoadDocumentsDialog { failed_document_names }; + dialog.send_dialog_to_frontend(responses); + } + // TODO: Eventually remove this document upgrade code + PortfolioMessage::DiscardFailedToLoadDocuments => { + let failed = std::mem::take(&mut self.failed_to_load_documents); + for document_id in failed.keys() { + self.document_ids.retain(|id| id != document_id); + responses.add(PersistentStateMessage::DeleteDocument { document_id: *document_id }); + } + responses.add(PortfolioMessage::UpdateOpenDocumentsList); + responses.add(PersistentStateMessage::WriteState); + } + // TODO: Eventually remove this document upgrade code + PortfolioMessage::DownloadFailedToLoadDocuments => { + if self.failed_to_load_documents.is_empty() { + return; + } + + let mut used_names: HashMap = HashMap::new(); + let files: Vec<(String, Vec)> = self + .failed_to_load_documents + .values() + .map(|(info, content)| { + let stem = sanitize_filename_stem(&info.name).unwrap_or_else(|| format!("document-{:x}", info.id.0)); + 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"; + + 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}"), + }); + } + } + } } PortfolioMessage::NewDocumentWithName { name } => { let mut new_document = DocumentMessageHandler::default(); @@ -769,11 +881,37 @@ 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(), - }); + // TODO: Eventually remove this document upgrade code + // TODO: (Only the `if` branch, the `else` branch's manual-open dialog stays) + if document_is_auto_saved { + let name = document_name.unwrap_or_default(); + let info = DocumentInfo { + id: document_id, + name, + resources: None, + 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 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}"); + 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; @@ -856,6 +994,11 @@ impl MessageHandler> for Portfolio self.load_document(document, document_id, responses); responses.add(AppWindowMessage::Focus); + + // TODO: Eventually remove this document upgrade code + 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}". @@ -1765,7 +1908,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::>(); + + // Keep failed-to-load docs referenced in `state.documents` so their autosave files survive `garbage_collect_document_files` + // TODO: Eventually remove this document upgrade code + for (info, _) in self.failed_to_load_documents.values() { + documents.push(info.clone()); + } PersistedState { documents, @@ -1809,6 +1958,18 @@ impl PortfolioMessageHandler { } } + // TODO: Eventually remove this document upgrade code + 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() { @@ -2018,3 +2179,76 @@ impl PortfolioMessageHandler { } } } + +// TODO: Eventually remove this document upgrade code +fn display_name_with_fallback(info: &DocumentInfo) -> String { + if info.name.trim().is_empty() { + format!("Untitled Document ({:x})", info.id.0) + } else { + info.name.clone() + } +} + +/// Returns `None` if the name has no safe filename characters left or matches a Windows reserved device name, so callers fall back to an ID-based stem. +// TODO: Eventually remove this document upgrade code +fn sanitize_filename_stem(name: &str) -> Option { + let replaced: String = name + .chars() + .map(|c| { + if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { + '_' + } else { + c + } + }) + .collect(); + + // Trim dots to avoid `.` / `..` resolving against the parent directory, and to dodge Windows' trailing-dot/space quirks + let trimmed = replaced.trim().trim_matches('.').trim(); + if trimmed.is_empty() { + return None; + } + + // Windows rejects these regardless of extension; superscript digits are normalized equivalently by some path APIs + let first_segment = trimmed.split('.').next().unwrap_or("").to_ascii_uppercase(); + if matches!( + first_segment.as_str(), + "CON" + | "PRN" | "AUX" + | "NUL" | "COM1" + | "COM2" | "COM3" + | "COM4" | "COM5" + | "COM6" | "COM7" + | "COM8" | "COM9" + | "COM¹" | "COM²" + | "COM³" | "LPT1" + | "LPT2" | "LPT3" + | "LPT4" | "LPT5" + | "LPT6" | "LPT7" + | "LPT8" | "LPT9" + | "LPT¹" | "LPT²" + | "LPT³" + ) { + return None; + } + + Some(trimmed.to_string()) +} + +// TODO: Eventually remove this document upgrade code +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); + 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()) +}