diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 884709316..dc2c3f510 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -6,37 +6,21 @@ use pdfcraft_platform::staging::{StagingName, staging_suffixes}; use crate::PdfCraftApp; -/// What the user was doing when we asked whether to save. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CloseRequest { - /// Close this document's tab. By id, not tab index: tabs can close and shift while a save - /// panel is open. - Tab(pdfcraft_engine::DocId), - /// Quit the application once every dirty document is resolved. - Quit, - /// File ▸ Close all: like Quit, but the application stays open. - All, -} +mod close; +mod save; -/// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file -/// on disk is another version than the document came from (#2166). +pub use close::CloseRequest; #[cfg(not(target_arch = "wasm32"))] -pub struct PendingOverwrite { - pub id: pdfcraft_engine::DocId, - pub dest: String, - target: SaveTarget, - /// What was to run once the document is written (close its tab, quit). - after: Box, -} - -/// Where Save writes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SaveTarget { - /// The file's own path; asks for one if the document has none (e.g. opened from bytes). - InPlace, - /// Always ask for a new location. - As, -} +pub(crate) use save::in_document_folder; +#[cfg(not(target_arch = "wasm32"))] +pub use save::PendingOverwrite; +pub use save::{SaveTarget, write_atomically}; +#[cfg(target_arch = "wasm32")] +pub(crate) use save::download; +#[cfg(test)] +pub(crate) use save::{is_mail_temp_copy, save_dialog_folder}; +#[cfg(test)] +use save::{write_atomically_syncing, write_atomically_with}; /// Text typed into a new text box that isn't added to the page yet. fn new_text_typed(v: &crate::DocView) -> bool { @@ -644,392 +628,6 @@ impl PdfCraftApp { || new_text_typed(v) || v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text()) } - - /// Save the active document. Returns `true` if it was written. - pub fn save_active(&mut self, target: SaveTarget) -> bool { - match self.active { - Some(i) => self.save_view(i, target), - None => false, - } - } - - /// Save the document shown in tab `index`. Returns `true` if it was written now. When it has - /// to ask where (a new document, or Save As) it returns `false` and saves on a later frame, - /// once the user has chosen. - pub fn save_view(&mut self, index: usize, target: SaveTarget) -> bool { - self.save_then(index, target, |_| {}) - } - - /// [`Self::save_view`], then `after` once the document is written: now, or on a later frame - /// when the user had to choose where. `after` never runs when the save fails or is cancelled. - pub(crate) fn save_then(&mut self, index: usize, target: SaveTarget, after: impl FnOnce(&mut Self) + Send + 'static) -> bool { - // What's typed in a field is part of what's saved (#166). - if let Some(id) = self.views.get(index).map(|v| v.id) - && !self.commit_typing_in(id) - { - return false; - } - let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; - let Some(doc) = self.session.get(id) else { return false }; - let (name, path) = (doc.name.clone(), doc.path.clone()); - #[cfg(not(target_arch = "wasm32"))] - { - let doc_path = path.clone(); - let destination = match (target, path, &self.save_override) { - (_, _, Some(p)) => p.clone(), - // An email attachment's temporary copy is no place to keep work: ask, as for a new - // document (`is_mail_temp_copy`). - (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, - _ => { - let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); - let dialog = in_document_folder(dialog, doc_path.as_deref()); - // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. - self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { - app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); - }); - return false; - } - }; - self.save_doc_to_then(id, &destination, target, Box::new(after)) - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - if !self.flatten_fill_sign_for_save(id) { - return false; - } - let bytes = match self.session.save_bytes(id) { - Ok(b) => b, - Err(e) => { - self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - return false; - } - }; - // An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise - // the browser downloads them. - let to_host = self.host_save.is_some(); - let delivered = match &self.host_save { - Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)), - None => download(&name, &bytes), - }; - match delivered { - Ok(()) => { - let _ = self.session.mark_saved(id, bytes, None); - if let Some(doc) = self.session.get(id) - && let Some(view) = self.views.get_mut(index) - { - view.document_changed(&doc.info); - } - // The host reports where the file went (and any failure) itself. - if !to_host { - self.notify_fmt("Downloaded {name}", &[("name", &name)]); - } - after(self); - true - } - Err(e) if to_host => { - self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]); - false - } - Err(e) => { - self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - false - } - } - } - } - - /// [`Self::save_doc_to`], then `after`. When `dest` is the document's own file and it is - /// another version on disk than the one the document came from (something else wrote it - /// since the open or the last save, #2166), it asks first: the save waits in - /// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged). - /// Returns `true` if written now. - #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { - let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); - if own && self.watch.disk_differs(id, dest, self.session.get(id).map(|d| d.saved_bytes())) { - self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); - self.dialog = Some(crate::Dialog::OverwriteChanged); - return false; - } - let saved = self.save_doc_to(id, dest, target); - if saved { - after(self); - } - saved - } - - /// The dialog's Save anyway: write over the changed file. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn overwrite_changed_file(&mut self) { - let Some(p) = self.pending_overwrite.take() else { return }; - if self.save_doc_to(p.id, &p.dest, p.target) { - (p.after)(self); - } - } - - /// The dialog's Save as…: leave the file on disk as it is and ask where to put this version. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn save_changed_file_elsewhere(&mut self) { - let Some(p) = self.pending_overwrite.take() else { return }; - if let Some(index) = self.views.iter().position(|v| v.id == p.id) { - self.save_then(index, SaveTarget::As, p.after); - } - } - - /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. - #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { - // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even - // if another tab is active by then. - if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { - return false; - } - let Some((name, full)) = self.session.get(id).map(|d| { - // Save As to another file is a clean copy, without the earlier revisions that still - // hold deleted pages or replaced text (#2165). A signed document stays incremental so - // its signatures keep covering the file. - let full = target == SaveTarget::As - && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) - && d.read_only_reason.is_none() - && !d.is_signed(); - (d.name.clone(), full) - }) else { - self.notify_tr("The document was closed before it could be saved."); - return false; - }; - let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; - let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { - Ok(b) => b, - Err(e) => { - self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); - return false; - } - }; - if let Err(e) = write_atomically(dest, &bytes) { - self.notify_fmt("Couldn't save {name}: {e}", &[("name", dest), ("e", &e.to_string())]); - return false; - } - // Our own write is not a change on disk to reload (#431). - self.watch.known_now(id, dest); - match self.session.mark_saved(id, bytes, Some(dest.to_string())) { - Ok(()) => { - self.forget_recovery(id); - if let Some(doc) = self.session.get(id) - && let Some(view) = self.views.iter_mut().find(|v| v.id == id) - { - view.document_changed(&doc.info); - // Saving over a version that changed on disk keeps this one: nothing to ask. - view.disk_changed = false; - } - self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); - #[cfg(target_os = "macos")] - crate::macos_association::repair(std::path::Path::new(dest)); - true - } - Err(e) => { - self.notify_fmt("Saved, but reopening failed: {e}", &[("e", &e.to_string())]); - false - } - } - } - - /// Close a tab, asking first if it has unsaved changes. - pub fn request_close_tab(&mut self, index: usize) { - // Text typed into a field counts as an unsaved change. - if self.has_unsaved_work(index) - && let Some(id) = self.views.get(index).map(|v| v.id) - { - self.close_request = Some(CloseRequest::Tab(id)); - } else { - self.close_tab(index); - } - } - - /// File ▸ Close all: clean documents close at once; each one with unsaved changes asks. - pub fn close_all(&mut self) { - for i in (0..self.views.len()).rev() { - if !self.has_unsaved_work(i) { - self.close_tab(i); - } - } - if self.first_dirty().is_some() { - self.close_request = Some(CloseRequest::All); - } - } - - /// File ▸ Revert (after the confirmation). - pub fn revert_active(&mut self) { - let Some((_, id)) = self.active_ids() else { return }; - match self.session.revert(id) { - Ok(()) => { - if let Some(i) = self.active - && let Some(d) = self.session.get(id) - { - let view = &mut self.views[i]; - view.document_changed(&d.info); - // Revert discards this source's uncommitted typing as well as model edits. - // Otherwise a later Save could write a rejected draft back into the file. - view.forms.focus = None; - view.forms.committed = None; - view.forms.unbaked.clear(); - view.pending_edit = None; - } - self.notify_tr("Reverted to the last saved version"); - } - Err(e) => self.notify_error(e), - } - } - - /// The first tab with unsaved changes. - pub fn first_dirty(&self) -> Option { - (0..self.views.len()).find(|&i| self.has_unsaved_work(i)) - } - - /// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel. - pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option) { - let Some(req) = self.close_request.take() else { return }; - // The next question (another dirty document) starts on Save again. - self.save_prompt_button = Default::default(); - let index = match req { - CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) { - Some(i) => i, - None => return, // already closed - }, - CloseRequest::Quit | CloseRequest::All => match self.first_dirty() { - Some(i) => i, - None => { - if req == CloseRequest::Quit { - self.quit(ctx); - } - return; - } - }, - }; - // Quitting closes the unsaved tabs one by one (and brings each forward to save it): - // remember what was open, and which tab was active, before the first one goes (#442). - if req == CloseRequest::Quit { - match choice { - Some(_) => self.note_quit_session(), - None => self.forget_quit_session(), - } - } - match choice { - None => {} // cancelled: nothing closes - Some(false) => self.close_and_continue(ctx, index, req), - Some(true) => { - let Some(id) = self.views.get(index).map(|v| v.id) else { return }; - // Bring the document forward: what's typed in its form is committed and saved - // with it, and a field that rejects the value is shown where the user can fix it. - self.active = Some(index); - // Close only once the save has actually been written, which may be on a later - // frame when the user has to choose where. A failed or cancelled save keeps the - // document open. - let ctx = ctx.clone(); - self.save_then(index, SaveTarget::InPlace, move |app| { - if let Some(i) = app.views.iter().position(|v| v.id == id) { - app.close_and_continue(&ctx, i, req); - } - }); - } - } - } - - /// Close tab `index` for a close request, then ask about the next dirty document, or quit. - fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { - self.close_tab(index); - // A newer prompt wins: the user may have started another close while a save was pending, - // and seen its question, which keeps counting as seen. - if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { - match self.first_dirty() { - Some(_) => { - // A question about the next document is a new one. - self.save_prompt_seen = false; - self.close_request = Some(req); - } - None if req == CloseRequest::Quit => self.quit(ctx), - None => {} - } - } - } - - /// Request a normal application exit, preserving every unsaved document until answered. - /// Returns false while the existing save/discard/cancel prompt needs a decision. - pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool { - if self.first_dirty().is_some() { - self.close_request = Some(CloseRequest::Quit); - ctx.request_repaint(); - false - } else { - self.shutdown_recovery(); - self.quit(ctx); - true - } - } - - fn quit(&mut self, ctx: &egui::Context) { - self.allow_quit = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - - /// Publish whether any document has unsaved work, for the browser's reload warning (#812). - pub(crate) fn sync_unsaved_flag(&self) { - self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed); - } - - /// Intercept window close while documents have unsaved changes. - pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) { - // egui's default quit shortcut ignores extra Alt/Shift, including Ctrl+Alt+Q for '@'. - ctx.options_mut(|o| o.quit_shortcuts.clear()); - let shortcut = ctx.input(|i| { - i.events.iter().any(|e| { - matches!(e, egui::Event::Key { key: egui::Key::Q, pressed: true, modifiers, .. } - if modifiers.matches_exact(egui::Modifiers::COMMAND)) - }) - }); - if shortcut { - self.request_quit(ctx); - } - self.sync_unsaved_flag(); - if !ctx.input(|i| i.viewport().close_requested()) { - return; - } - // `first_dirty` counts text typed into a field as an unsaved change. - if !self.allow_quit && self.first_dirty().is_some() { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - self.close_request = Some(CloseRequest::Quit); - } else { - // A clean quit: nothing is left to recover. - self.shutdown_recovery(); - } - } - - /// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in - /// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the - /// request once the user comes back or the question is answered. - /// - /// The question holds up the quit the user asked for, so the request is critical, as AppKit - /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until - /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and - /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front - /// asks nothing, and neither does leaving it open: the user has seen it. Each document asked - /// about when quitting is a new question, so one that opens in the background asks even after - /// a question the user saw; a request that still stands carries over to it. - pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { - let open = self.close_request.is_some(); - let focused = ctx.input(|i| i.focused); - if self.save_prompt_attention { - if focused || !open { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); - self.save_prompt_attention = false; - } - } else if open && !self.save_prompt_seen && !focused { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); - self.save_prompt_attention = true; - } - self.save_prompt_seen = open; - } } /// The single page a content edit changes (`None` for other edits). Like comment edits, @@ -1084,97 +682,6 @@ fn is_form_edit(edit: &Edit) -> bool { ) } -/// Whether `path` is the temporary copy an email client opened an attachment from, so that Save -/// asks where to keep the document instead of writing to it. -/// -/// Outlook opens an attachment from its own cache: `INetCache\Content.Outlook\\` for -/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the -/// `Packages\Microsoft.OutlookForWindows_` package folder for the new Outlook. The patterns -/// follow those layouts, so an ordinary folder that merely shares a name doesn't match. Saving there either fails, -/// because Outlook can still hold the file, or appears to work while the edits stay out of the -/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads//` in -/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on -/// either separator and in any case, so it can be tested on every platform. -pub(crate) fn is_mail_temp_copy(path: &str) -> bool { - let p = path.replace('\\', "/").to_ascii_lowercase(); - // Outlook's cache holds `/`: exactly one folder, then the file. - let in_outlook_cache = |marker: &str| { - p.split_once(marker) - .is_some_and(|(_, rest)| matches!(rest.split('/').collect::>()[..], [dir, file] if !dir.is_empty() && !file.is_empty())) - }; - in_outlook_cache("/inetcache/content.outlook/") - || in_outlook_cache("/temporary internet files/content.outlook/") - || p.contains("/packages/microsoft.outlookforwindows_") - || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") -} - -/// The folder a Save As style dialog should open in for a document stored at `path`: the -/// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks. -/// `None` (keep the system's choice) for a new document, a path with no parent, or an email -/// attachment's temporary copy (`is_mail_temp_copy`), which is no place to keep work. Also right -/// for UNC paths on Windows (`\\server\share\dir\f.pdf` gives `\\server\share\dir`). -pub(crate) fn save_dialog_folder(path: Option<&str>) -> Option { - let path = path.filter(|p| !is_mail_temp_copy(p))?; - let parent = std::path::Path::new(path).parent()?; - (!parent.as_os_str().is_empty()).then(|| parent.to_path_buf()) -} - -/// `dialog` opened in the folder of the document stored at `path` ([`save_dialog_folder`]). -pub(crate) fn in_document_folder(dialog: rfd::AsyncFileDialog, path: Option<&str>) -> rfd::AsyncFileDialog { - match save_dialog_folder(path) { - Some(dir) => dialog.set_directory(dir), - None => dialog, - } -} - -/// Write via a temporary file in the same directory and rename over the target, so a crash or -/// full disk never leaves a half-written PDF where the original was. -pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> { - write_atomically_with(path, bytes, staging_suffixes()) -} - -/// [`write_atomically`], trying the staging names that `suffixes` give. -fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator) -> std::io::Result<()> { - write_atomically_syncing(path, bytes, suffixes, std::fs::File::sync_all) -} - -/// [`write_atomically_with`], flushing the staging file with `sync_all` (the standard library's -/// in the app, a failing one in tests). The writer itself is the platform crate's, shared with -/// the tools and the CLI (#2166); the desktop app keeps its staging-name form. -fn write_atomically_syncing( - path: &str, - bytes: &[u8], - suffixes: impl IntoIterator, - sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, -) -> std::io::Result<()> { - pdfcraft_platform::staging::write_atomically_with(std::path::Path::new(path), bytes, StagingName::TagThenSuffix, suffixes, sync_all) -} - -#[cfg(not(target_arch = "wasm32"))] -fn short_name(path: &str) -> String { - std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) -} - -/// Offer bytes as a browser download. -#[cfg(target_arch = "wasm32")] -pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { - use wasm_bindgen::JsCast; - let err = |e: wasm_bindgen::JsValue| format!("{e:?}"); - let array = js_sys::Uint8Array::from(bytes); - let parts = js_sys::Array::of1(&array); - let opts = web_sys::BlobPropertyBag::new(); - opts.set_type("application/pdf"); - let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?; - let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?; - let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; - let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(err)?.dyn_into().map_err(|_| "anchor")?; - a.set_href(&url); - a.set_download(name); - a.click(); - let _ = web_sys::Url::revoke_object_url(&url); - Ok(()) -} - impl PdfCraftApp { /// Carry out a Bookmarks-panel action as an undoable edit. pub fn bookmark_action(&mut self, action: crate::panels::BmAction) { diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs new file mode 100644 index 000000000..5317c0ccd --- /dev/null +++ b/crates/ui-egui/src/editing/close.rs @@ -0,0 +1,212 @@ +use super::save::SaveTarget; +use super::*; + +/// What the user was doing when we asked whether to save. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloseRequest { + /// Close this document's tab. By id, not tab index: tabs can close and shift while a save + /// panel is open. + Tab(pdfcraft_engine::DocId), + /// Quit the application once every dirty document is resolved. + Quit, + /// File ▸ Close all: like Quit, but the application stays open. + All, +} + +impl PdfCraftApp { + /// Close a tab, asking first if it has unsaved changes. + pub fn request_close_tab(&mut self, index: usize) { + // Text typed into a field counts as an unsaved change. + if self.has_unsaved_work(index) + && let Some(id) = self.views.get(index).map(|v| v.id) + { + self.close_request = Some(CloseRequest::Tab(id)); + } else { + self.close_tab(index); + } + } + + /// File ▸ Close all: clean documents close at once; each one with unsaved changes asks. + pub fn close_all(&mut self) { + for i in (0..self.views.len()).rev() { + if !self.has_unsaved_work(i) { + self.close_tab(i); + } + } + if self.first_dirty().is_some() { + self.close_request = Some(CloseRequest::All); + } + } + + /// File ▸ Revert (after the confirmation). + pub fn revert_active(&mut self) { + let Some((_, id)) = self.active_ids() else { return }; + match self.session.revert(id) { + Ok(()) => { + if let Some(i) = self.active + && let Some(d) = self.session.get(id) + { + let view = &mut self.views[i]; + view.document_changed(&d.info); + // Revert discards this source's uncommitted typing as well as model edits. + // Otherwise a later Save could write a rejected draft back into the file. + view.forms.focus = None; + view.forms.committed = None; + view.forms.unbaked.clear(); + view.pending_edit = None; + } + self.notify_tr("Reverted to the last saved version"); + } + Err(e) => self.notify_error(e), + } + } + + /// The first tab with unsaved changes. + pub fn first_dirty(&self) -> Option { + (0..self.views.len()).find(|&i| self.has_unsaved_work(i)) + } + + /// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel. + pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option) { + let Some(req) = self.close_request.take() else { return }; + // The next question (another dirty document) starts on Save again. + self.save_prompt_button = Default::default(); + let index = match req { + CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) { + Some(i) => i, + None => return, // already closed + }, + CloseRequest::Quit | CloseRequest::All => match self.first_dirty() { + Some(i) => i, + None => { + if req == CloseRequest::Quit { + self.quit(ctx); + } + return; + } + }, + }; + // Quitting closes the unsaved tabs one by one (and brings each forward to save it): + // remember what was open, and which tab was active, before the first one goes (#442). + if req == CloseRequest::Quit { + match choice { + Some(_) => self.note_quit_session(), + None => self.forget_quit_session(), + } + } + match choice { + None => {} // cancelled: nothing closes + Some(false) => self.close_and_continue(ctx, index, req), + Some(true) => { + let Some(id) = self.views.get(index).map(|v| v.id) else { return }; + // Bring the document forward: what's typed in its form is committed and saved + // with it, and a field that rejects the value is shown where the user can fix it. + self.active = Some(index); + // Close only once the save has actually been written, which may be on a later + // frame when the user has to choose where. A failed or cancelled save keeps the + // document open. + let ctx = ctx.clone(); + self.save_then(index, SaveTarget::InPlace, move |app| { + if let Some(i) = app.views.iter().position(|v| v.id == id) { + app.close_and_continue(&ctx, i, req); + } + }); + } + } + } + + /// Close tab `index` for a close request, then ask about the next dirty document, or quit. + pub(super) fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { + self.close_tab(index); + // A newer prompt wins: the user may have started another close while a save was pending, + // and seen its question, which keeps counting as seen. + if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { + match self.first_dirty() { + Some(_) => { + // A question about the next document is a new one. + self.save_prompt_seen = false; + self.close_request = Some(req); + } + None if req == CloseRequest::Quit => self.quit(ctx), + None => {} + } + } + } + + /// Request a normal application exit, preserving every unsaved document until answered. + /// Returns false while the existing save/discard/cancel prompt needs a decision. + pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool { + if self.first_dirty().is_some() { + self.close_request = Some(CloseRequest::Quit); + ctx.request_repaint(); + false + } else { + self.shutdown_recovery(); + self.quit(ctx); + true + } + } + + fn quit(&mut self, ctx: &egui::Context) { + self.allow_quit = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + /// Publish whether any document has unsaved work, for the browser's reload warning (#812). + pub(crate) fn sync_unsaved_flag(&self) { + self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed); + } + + /// Intercept window close while documents have unsaved changes. + pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) { + // egui's default quit shortcut ignores extra Alt/Shift, including Ctrl+Alt+Q for '@'. + ctx.options_mut(|o| o.quit_shortcuts.clear()); + let shortcut = ctx.input(|i| { + i.events.iter().any(|e| { + matches!(e, egui::Event::Key { key: egui::Key::Q, pressed: true, modifiers, .. } + if modifiers.matches_exact(egui::Modifiers::COMMAND)) + }) + }); + if shortcut { + self.request_quit(ctx); + } + self.sync_unsaved_flag(); + if !ctx.input(|i| i.viewport().close_requested()) { + return; + } + // `first_dirty` counts text typed into a field as an unsaved change. + if !self.allow_quit && self.first_dirty().is_some() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.close_request = Some(CloseRequest::Quit); + } else { + // A clean quit: nothing is left to recover. + self.shutdown_recovery(); + } + } + + /// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in + /// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the + /// request once the user comes back or the question is answered. + /// + /// The question holds up the quit the user asked for, so the request is critical, as AppKit + /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until + /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and + /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front + /// asks nothing, and neither does leaving it open: the user has seen it. Each document asked + /// about when quitting is a new question, so one that opens in the background asks even after + /// a question the user saw; a request that still stands carries over to it. + pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { + let open = self.close_request.is_some(); + let focused = ctx.input(|i| i.focused); + if self.save_prompt_attention { + if focused || !open { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); + self.save_prompt_attention = false; + } + } else if open && !self.save_prompt_seen && !focused { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); + self.save_prompt_attention = true; + } + self.save_prompt_seen = open; + } +} diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs new file mode 100644 index 000000000..59eba1fbc --- /dev/null +++ b/crates/ui-egui/src/editing/save.rs @@ -0,0 +1,304 @@ +use super::*; + +/// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file +/// on disk is another version than the document came from (#2166). +#[cfg(not(target_arch = "wasm32"))] +pub struct PendingOverwrite { + pub id: pdfcraft_engine::DocId, + pub dest: String, + target: SaveTarget, + /// What was to run once the document is written (close its tab, quit). + after: Box, +} + +/// Where Save writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveTarget { + /// The file's own path; asks for one if the document has none (e.g. opened from bytes). + InPlace, + /// Always ask for a new location. + As, +} + +impl PdfCraftApp { + /// Save the active document. Returns `true` if it was written. + pub fn save_active(&mut self, target: SaveTarget) -> bool { + match self.active { + Some(i) => self.save_view(i, target), + None => false, + } + } + + /// Save the document shown in tab `index`. Returns `true` if it was written now. When it has + /// to ask where (a new document, or Save As) it returns `false` and saves on a later frame, + /// once the user has chosen. + pub fn save_view(&mut self, index: usize, target: SaveTarget) -> bool { + self.save_then(index, target, |_| {}) + } + + /// [`Self::save_view`], then `after` once the document is written: now, or on a later frame + /// when the user had to choose where. `after` never runs when the save fails or is cancelled. + pub(crate) fn save_then(&mut self, index: usize, target: SaveTarget, after: impl FnOnce(&mut Self) + Send + 'static) -> bool { + // What's typed in a field is part of what's saved (#166). + if let Some(id) = self.views.get(index).map(|v| v.id) + && !self.commit_typing_in(id) + { + return false; + } + let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; + let Some(doc) = self.session.get(id) else { return false }; + let (name, path) = (doc.name.clone(), doc.path.clone()); + #[cfg(not(target_arch = "wasm32"))] + { + let doc_path = path.clone(); + let destination = match (target, path, &self.save_override) { + (_, _, Some(p)) => p.clone(), + // An email attachment's temporary copy is no place to keep work: ask, as for a new + // document (`is_mail_temp_copy`). + (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, + _ => { + let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; + let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); + let dialog = in_document_folder(dialog, doc_path.as_deref()); + // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. + self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { + app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); + }); + return false; + } + }; + self.save_doc_to_then(id, &destination, target, Box::new(after)) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + if !self.flatten_fill_sign_for_save(id) { + return false; + } + let bytes = match self.session.save_bytes(id) { + Ok(b) => b, + Err(e) => { + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + return false; + } + }; + // An embedding page that asked for saves (`?host=parent`) gets the bytes; otherwise + // the browser downloads them. + let to_host = self.host_save.is_some(); + let delivered = match &self.host_save { + Some(save) => save(&name, &bytes, matches!(target, SaveTarget::As)), + None => download(&name, &bytes), + }; + match delivered { + Ok(()) => { + let _ = self.session.mark_saved(id, bytes, None); + if let Some(doc) = self.session.get(id) + && let Some(view) = self.views.get_mut(index) + { + view.document_changed(&doc.info); + } + // The host reports where the file went (and any failure) itself. + if !to_host { + self.notify_fmt("Downloaded {name}", &[("name", &name)]); + } + after(self); + true + } + Err(e) if to_host => { + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e)]); + false + } + Err(e) => { + self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + false + } + } + } + } + + /// [`Self::save_doc_to`], then `after`. When `dest` is the document's own file and it is + /// another version on disk than the one the document came from (something else wrote it + /// since the open or the last save, #2166), it asks first: the save waits in + /// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged). + /// Returns `true` if written now. + #[cfg(not(target_arch = "wasm32"))] + fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { + let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); + if own && self.watch.disk_differs(id, dest, self.session.get(id).map(|d| d.saved_bytes())) { + self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); + self.dialog = Some(crate::Dialog::OverwriteChanged); + return false; + } + let saved = self.save_doc_to(id, dest, target); + if saved { + after(self); + } + saved + } + + /// The dialog's Save anyway: write over the changed file. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn overwrite_changed_file(&mut self) { + let Some(p) = self.pending_overwrite.take() else { return }; + if self.save_doc_to(p.id, &p.dest, p.target) { + (p.after)(self); + } + } + + /// The dialog's Save as…: leave the file on disk as it is and ask where to put this version. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn save_changed_file_elsewhere(&mut self) { + let Some(p) = self.pending_overwrite.take() else { return }; + if let Some(index) = self.views.iter().position(|v| v.id == p.id) { + self.save_then(index, SaveTarget::As, p.after); + } + } + + /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. + #[cfg(not(target_arch = "wasm32"))] + fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { + // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even + // if another tab is active by then. + if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { + return false; + } + let Some((name, full)) = self.session.get(id).map(|d| { + // Save As to another file is a clean copy, without the earlier revisions that still + // hold deleted pages or replaced text (#2165). A signed document stays incremental so + // its signatures keep covering the file. + let full = target == SaveTarget::As + && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) + && d.read_only_reason.is_none() + && !d.is_signed(); + (d.name.clone(), full) + }) else { + self.notify_tr("The document was closed before it could be saved."); + return false; + }; + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; + let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { + Ok(b) => b, + Err(e) => { + self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + return false; + } + }; + if let Err(e) = write_atomically(dest, &bytes) { + self.notify_fmt("Couldn't save {name}: {e}", &[("name", dest), ("e", &e.to_string())]); + return false; + } + // Our own write is not a change on disk to reload (#431). + self.watch.known_now(id, dest); + match self.session.mark_saved(id, bytes, Some(dest.to_string())) { + Ok(()) => { + self.forget_recovery(id); + if let Some(doc) = self.session.get(id) + && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + { + view.document_changed(&doc.info); + // Saving over a version that changed on disk keeps this one: nothing to ask. + view.disk_changed = false; + } + self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); + #[cfg(target_os = "macos")] + crate::macos_association::repair(std::path::Path::new(dest)); + true + } + Err(e) => { + self.notify_fmt("Saved, but reopening failed: {e}", &[("e", &e.to_string())]); + false + } + } + } +} + +/// Whether `path` is the temporary copy an email client opened an attachment from, so that Save +/// asks where to keep the document instead of writing to it. +/// +/// Outlook opens an attachment from its own cache: `INetCache\Content.Outlook\\` for +/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the +/// `Packages\Microsoft.OutlookForWindows_` package folder for the new Outlook. The patterns +/// follow those layouts, so an ordinary folder that merely shares a name doesn't match. Saving there either fails, +/// because Outlook can still hold the file, or appears to work while the edits stay out of the +/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads//` in +/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on +/// either separator and in any case, so it can be tested on every platform. +pub(crate) fn is_mail_temp_copy(path: &str) -> bool { + let p = path.replace('\\', "/").to_ascii_lowercase(); + // Outlook's cache holds `/`: exactly one folder, then the file. + let in_outlook_cache = |marker: &str| { + p.split_once(marker) + .is_some_and(|(_, rest)| matches!(rest.split('/').collect::>()[..], [dir, file] if !dir.is_empty() && !file.is_empty())) + }; + in_outlook_cache("/inetcache/content.outlook/") + || in_outlook_cache("/temporary internet files/content.outlook/") + || p.contains("/packages/microsoft.outlookforwindows_") + || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") +} + +/// The folder a Save As style dialog should open in for a document stored at `path`: the +/// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks. +/// `None` (keep the system's choice) for a new document, a path with no parent, or an email +/// attachment's temporary copy (`is_mail_temp_copy`), which is no place to keep work. Also right +/// for UNC paths on Windows (`\\server\share\dir\f.pdf` gives `\\server\share\dir`). +pub(crate) fn save_dialog_folder(path: Option<&str>) -> Option { + let path = path.filter(|p| !is_mail_temp_copy(p))?; + let parent = std::path::Path::new(path).parent()?; + (!parent.as_os_str().is_empty()).then(|| parent.to_path_buf()) +} + +/// `dialog` opened in the folder of the document stored at `path` ([`save_dialog_folder`]). +pub(crate) fn in_document_folder(dialog: rfd::AsyncFileDialog, path: Option<&str>) -> rfd::AsyncFileDialog { + match save_dialog_folder(path) { + Some(dir) => dialog.set_directory(dir), + None => dialog, + } +} + +/// Write via a temporary file in the same directory and rename over the target, so a crash or +/// full disk never leaves a half-written PDF where the original was. +pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> { + write_atomically_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomically`], trying the staging names that `suffixes` give. +pub(super) fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator) -> std::io::Result<()> { + write_atomically_syncing(path, bytes, suffixes, std::fs::File::sync_all) +} + +/// [`write_atomically_with`], flushing the staging file with `sync_all` (the standard library's +/// in the app, a failing one in tests). The writer itself is the platform crate's, shared with +/// the tools and the CLI (#2166); the desktop app keeps its staging-name form. +pub(super) fn write_atomically_syncing( + path: &str, + bytes: &[u8], + suffixes: impl IntoIterator, + sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> std::io::Result<()> { + pdfcraft_platform::staging::write_atomically_with(std::path::Path::new(path), bytes, StagingName::TagThenSuffix, suffixes, sync_all) +} + +#[cfg(not(target_arch = "wasm32"))] +fn short_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) +} + +/// Offer bytes as a browser download. +#[cfg(target_arch = "wasm32")] +pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { + use wasm_bindgen::JsCast; + let err = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let array = js_sys::Uint8Array::from(bytes); + let parts = js_sys::Array::of1(&array); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("application/pdf"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?; + let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(err)?.dyn_into().map_err(|_| "anchor")?; + a.set_href(&url); + a.set_download(name); + a.click(); + let _ = web_sys::Url::revoke_object_url(&url); + Ok(()) +}