mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +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:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"))'] }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user