mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-02 03:18:12 +08:00
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:
Generated
+19
@@ -2192,6 +2192,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"wgpu-executor",
|
"wgpu-executor",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5957,6 +5958,12 @@ dependencies = [
|
|||||||
"core_maths",
|
"core_maths",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -7655,6 +7662,18 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ lzma-rust2 = { version = "0.16", default-features = false, features = ["std", "e
|
|||||||
scraper = "0.25"
|
scraper = "0.25"
|
||||||
linesweeper = "0.3"
|
linesweeper = "0.3"
|
||||||
smallvec = "1.13.2"
|
smallvec = "1.13.2"
|
||||||
|
zip = { version = "8", default-features = false }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] }
|
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] }
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ base64 = { workspace = true }
|
|||||||
spin = { workspace = true }
|
spin = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
color = { workspace = true }
|
color = { workspace = true }
|
||||||
|
zip = { workspace = true }
|
||||||
|
|
||||||
# Optional local dependencies
|
# Optional local dependencies
|
||||||
wgpu-executor = { workspace = true, optional = true }
|
wgpu-executor = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -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()]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ mod close_document_dialog;
|
|||||||
mod confirm_restart_dialog;
|
mod confirm_restart_dialog;
|
||||||
mod demo_artwork_dialog;
|
mod demo_artwork_dialog;
|
||||||
mod error_dialog;
|
mod error_dialog;
|
||||||
|
mod failed_to_load_documents_dialog;
|
||||||
|
mod failed_to_open_document_dialog;
|
||||||
mod licenses_dialog;
|
mod licenses_dialog;
|
||||||
mod licenses_third_party_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::ARTWORK;
|
||||||
pub use demo_artwork_dialog::DemoArtworkDialog;
|
pub use demo_artwork_dialog::DemoArtworkDialog;
|
||||||
pub use error_dialog::ErrorDialog;
|
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_dialog::LicensesDialog;
|
||||||
pub use licenses_third_party_dialog::LicensesThirdPartyDialog;
|
pub use licenses_third_party_dialog::LicensesThirdPartyDialog;
|
||||||
|
|||||||
@@ -16,13 +16,7 @@ pub enum EditorError {
|
|||||||
#[error("The operation caused a document error:\n{0:?}")]
|
#[error("The operation caused a document error:\n{0:?}")]
|
||||||
Document(String),
|
Document(String),
|
||||||
|
|
||||||
#[error(
|
#[error("Failed to deserialize document: {0}")]
|
||||||
"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."
|
|
||||||
)]
|
|
||||||
DocumentDeserialization(String),
|
DocumentDeserialization(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ pub enum PortfolioMessage {
|
|||||||
document_id: DocumentId,
|
document_id: DocumentId,
|
||||||
document_serialized_content: String,
|
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 {
|
MoveAllPanelTabs {
|
||||||
source_group: PanelGroupId,
|
source_group: PanelGroupId,
|
||||||
target_group: PanelGroupId,
|
target_group: PanelGroupId,
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ pub struct PortfolioMessageContext<'a> {
|
|||||||
pub struct PortfolioMessageHandler {
|
pub struct PortfolioMessageHandler {
|
||||||
pub documents: HashMap<DocumentId, DocumentMessageHandler>,
|
pub documents: HashMap<DocumentId, DocumentMessageHandler>,
|
||||||
unloaded_documents: HashMap<DocumentId, DocumentInfo>,
|
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>,
|
document_ids: VecDeque<DocumentId>,
|
||||||
pub(crate) active_document_id: Option<DocumentId>,
|
pub(crate) active_document_id: Option<DocumentId>,
|
||||||
persistent_state: PersistentStateMessageHandler,
|
persistent_state: PersistentStateMessageHandler,
|
||||||
@@ -493,11 +503,17 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||||||
workspace_layout: _,
|
workspace_layout: _,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
|
// TODO: Eventually remove this document upgrade code
|
||||||
|
let mut newly_unloaded_ids = Vec::new();
|
||||||
|
|
||||||
for info in documents {
|
for info in documents {
|
||||||
if !self.document_ids.contains(&info.id) {
|
if !self.document_ids.contains(&info.id) {
|
||||||
self.document_ids.push_back(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);
|
self.unloaded_documents.insert(info.id, info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,9 +521,27 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||||||
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||||
|
|
||||||
let select_document_id = current_document.filter(|id| self.document_ids.contains(id)).or_else(|| self.document_ids.front().copied());
|
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 {
|
if let Some(document_id) = select_document_id {
|
||||||
responses.add(PortfolioMessage::SelectDocument { 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 {
|
PortfolioMessage::LoadDocumentContent {
|
||||||
document_id,
|
document_id,
|
||||||
@@ -526,7 +560,85 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||||||
document_is_saved: info.is_saved,
|
document_is_saved: info.is_saved,
|
||||||
document_serialized_content,
|
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<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 } => {
|
PortfolioMessage::NewDocumentWithName { name } => {
|
||||||
let mut new_document = DocumentMessageHandler::default();
|
let mut new_document = DocumentMessageHandler::default();
|
||||||
@@ -769,11 +881,37 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||||||
let mut document = match document {
|
let mut document = match document {
|
||||||
Ok(document) => document,
|
Ok(document) => document,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if !document_is_auto_saved {
|
// TODO: Eventually remove this document upgrade code
|
||||||
responses.add(DialogMessage::DisplayDialogError {
|
// TODO: (Only the `if` branch, the `else` branch's manual-open dialog stays)
|
||||||
title: "Failed to open document".to_string(),
|
if document_is_auto_saved {
|
||||||
description: e.to_string(),
|
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;
|
return;
|
||||||
@@ -856,6 +994,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
|||||||
self.load_document(document, document_id, responses);
|
self.load_document(document, document_id, responses);
|
||||||
|
|
||||||
responses.add(AppWindowMessage::Focus);
|
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 } => {
|
PortfolioMessage::OpenImage { name, image } => {
|
||||||
// `NewDocumentWithName`'s handler routes empty/None-equivalent names through `resolve_document_name` which assigns the next available "Untitled Document {N}".
|
// `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 {
|
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 {
|
PersistedState {
|
||||||
documents,
|
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 {
|
fn read_file(path: &PathBuf, content: Vec<u8>) -> FileContent {
|
||||||
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default().to_lowercase();
|
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default().to_lowercase();
|
||||||
match extension.as_str() {
|
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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user