Add a recovery path for autosaved documents that don't open successfully (#4157)

* Add a recovery path for autosaved documents that don't open successfully

* Fix file naming edge case

* Add edge cases for Windows zip names

* Simplify comments and remove desktop portion
This commit is contained in:
Keavon Chambers
2026-05-22 21:14:28 -07:00
committed by GitHub
parent c3b0c87cfe
commit 62287d7893
9 changed files with 471 additions and 15 deletions

19
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"))'] }

View File

@@ -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 }

View File

@@ -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<String>,
}
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()]),
])
}
}

View File

@@ -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(),
]),
])
}
}

View File

@@ -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;

View File

@@ -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}")]

View File

@@ -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,

View File

@@ -55,6 +55,16 @@ pub struct PortfolioMessageContext<'a> {
pub struct PortfolioMessageHandler {
pub documents: HashMap<DocumentId, DocumentMessageHandler>,
unloaded_documents: HashMap<DocumentId, DocumentInfo>,
/// 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<DocumentId, (DocumentInfo, String)>,
/// 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<DocumentId>,
document_ids: VecDeque<DocumentId>,
pub(crate) active_document_id: Option<DocumentId>,
persistent_state: PersistentStateMessageHandler,
@@ -493,11 +503,17 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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,8 +560,86 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
document_is_saved: info.is_saved,
document_serialized_content,
});
// 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<String, u32> = HashMap::new();
let files: Vec<(String, Vec<u8>)> = 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();
new_document.name = self.resolve_document_name(name, None);
@@ -769,11 +881,37 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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::<Vec<_>>();
let mut documents = self.document_ids.iter().filter_map(|id| self.document_details(*id)).collect::<Vec<_>>();
// 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<Message>, 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<u8>) -> 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<String> {
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<u8>)]) -> Result<Vec<u8>, String> {
use std::io::{Cursor, Write};
use zip::write::{SimpleFileOptions, ZipWriter};
let mut buffer = Cursor::new(Vec::<u8>::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())
}