From 3de2be8a6801ae5e4f92f9610b49e23a95f5b0c0 Mon Sep 17 00:00:00 2001 From: pauliusiv123-rgb <235694617+pauliusiv123-rgb@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:16:10 +0300 Subject: [PATCH] Issue #442: reopen the files that were open when PdfCraft last closed (#462) --- ATTRIBUTION.toml | 18 ++-- apps/pdfcraft/src/main.rs | 3 + crates/ui-egui/src/editing.rs | 8 ++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/js_ui.rs | 3 + crates/ui-egui/src/last_session.rs | 159 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 18 ++++ crates/ui-egui/tests/editing.rs | 113 ++++++++++++++++++++ parity/acrobat-features.toml | 12 +++ 17 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/last_session.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3a1801a25..098c5989e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "50ec1133889f3a3948a3901a9fa40b3499f3c549b605e55848138483946d74da" +sha256 = "8444a3bd135d61feb6c5b559a9eb0babb50de2ebbd1d86b1e87ccb064e2d3f0b" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3746a7d87fb98c9f3aeb41c63ce50371c42dfebca64b67ccbb599abb36a93ba8" +sha256 = "8771d80405b8fba9da4a909fdd97b760f6b25a822807ca83ff20dc5cec83a73c" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2c09a121e493fb123d9f886529f62268119ca580ad5d5300eca61d4a39216827" +sha256 = "bf1ab8079af725264f9c4298b9737359ba831170dcba76a6bd39228239c87e83" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0ccf62e34424981d5980eeaee8799db2653a4c186807df143326ef9881959006" +sha256 = "b23b03cf4ac163e7a63e49a0900a125507c05ad4fc7930468b859acf32b09ecd" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0b579afad6e52b98cef5e61e07e238d9cf76cc7f295395f7e1c855400e5fbaf9" +sha256 = "1b3371f239ca89a658e2c9b80cc070be5cd941dd1ecd3e7a247696716f25721d" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "956753e05b5a9e1d4c4a63ee57667cc9fa5bb95288c3d46c6b15435b83d6ed8d" +sha256 = "dbf725ffc9177a435e894f4405834e87c70b6fe31a34f1be533c60c356510b97" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e8b96edd718a1cd212b7440c4ab100f2555c97dccb0cda289bdb1775d5f3aa61" +sha256 = "f7a5ab0bcbdb8641b1bd4ae25db54ef187757dde3e4fe372e433a9e5c0fc4f3a" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "136a9ae206c4ee5aad29d248613c568799080b4b3ee9cd197233072ee9a2983a" +sha256 = "259f795a3cdc032aa670693d2ebff010c5dd3bcf57dda11548d5bd46e78969b8" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d2e7114c70da3c52200cf68e9023dc32ff462439795784e49c2ae771531240e2" +sha256 = "cdcdba828efea9d402cb9ce8be2b52ab1d58c8f7837d75d3b9c86f2a05e7fb2e" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index e40607a85..3d7f61a5e 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -224,6 +224,9 @@ fn main() -> eframe::Result { app.notify(e); } } else { + // With the preference on, last session's files come back first; files named on + // the command line open after them, in front (#442). + app.reopen_last_files(&files); for f in files { app.open_path(&f); } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 1d1b69025..57c2fef20 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -464,6 +464,14 @@ impl PdfCraftApp { } }, }; + // 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), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 75cf6d508..af17a38f3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -108,6 +108,7 @@ Fit to width scrolling Plynulé posouvání s přizpůsobením šířce Fit one full page Celá jedna stránka Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče. + Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Vyberte obrázek PNG nebo JPEG (nejvýše 4 MiB). Průhlednost bude zachována. Couldn't import the signature image: {e} Obrázek podpisu nelze importovat: {e} Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Přenosný režim je vypnutý: do složky {folder} nelze zapisovat ({error}). Nastavení se místo toho ukládá do vaší uživatelské složky. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index afc29fbcd..c1e3d2fd8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1986,6 +1986,7 @@ Default page display Presentación de página predeterminada Default zoom Zoom predeterminado Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página. + Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez Created a PDF from {n} files Se creó un PDF a partir de {n} archivos PDF, images and text PDF, imágenes y texto Insert a file before page {label} Insertar un archivo antes de la página {label} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 64a86273f..1d27e04f4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1909,6 +1909,7 @@ Default page display Affichage des pages par défaut Default zoom Zoom par défaut Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre. + Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft Show Afficher Sort Trier Reverse the order Inverser l'ordre diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ca53e9830..bd36ccd75 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1975,3 +1975,4 @@ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG または JPEG 画像を選択してください(最大 4 MiB)。透明度は保持されます。 Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e} Go to page… ページへ移動… + Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d9048a96b..8e38337de 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -66,3 +66,4 @@ Couldn't import the signature image: {e} Não foi possível importar a imagem da assinatura: {e} Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. O modo portátil está desativado: não é possível gravar em {folder} ({error}). As configurações ficam na sua pasta de usuário. Go to page… Ir para a página… + Reopen the files that were open when PdfCraft last closed Reabrir os arquivos que estavam abertos quando o PdfCraft foi fechado pela última vez diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5b91f5594..167525de6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1972,6 +1972,7 @@ Default page display Отображение страниц по умолчанию Default zoom Масштаб по умолчанию Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами. + Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Выберите изображение PNG или JPEG (до 4 МиБ). Прозрачность сохраняется. Couldn't import the signature image: {e} Не удалось импортировать изображение подписи: {e} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 1e381f12b..81f47b475 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1911,6 +1911,7 @@ action wizard Start ప్రారంభించు Default page display డిఫాల్ట్ పేజీ ప్రదర్శన Default zoom డిఫాల్ట్ జూమ్ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు. + Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు About పరిచయం Contributors సహకారులు Models మోడల్‌లు diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5e4a6a3db..5b66f382f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2005,6 +2005,7 @@ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). 尚未安装文本识别功能:缺少模型(请运行 `cargo xtask models` 或设置 PDFCRAFT_MODELS)。 Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。 Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。 + Reopen the files that were open when PdfCraft last closed 重新打开上次关闭 PdfCraft 时打开的文件 Write this email? 是否撰写此邮件? {who} in this document wants to open this address in your browser: 此文档中的{who}请求在浏览器中打开以下地址: {who} in this document wants to open your email app with this address: 此文档中的{who}请求打开邮件应用并使用以下地址: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7696480c3..9a9e1e851 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1968,3 +1968,4 @@ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 選擇 PNG 或 JPEG 圖像(最大 4 MiB)。透明度會保留。 Couldn't import the signature image: {e} 無法匯入簽名圖像:{e} Go to page… 轉到頁面… + Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 53665e29f..fb3059b1b 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -295,6 +295,9 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .small() .color(t.text_muted), ); + // The web build has no file paths to reopen. + #[cfg(not(target_arch = "wasm32"))] + ui.checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed")); ui.add_space(8.0); // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); diff --git a/crates/ui-egui/src/last_session.rs b/crates/ui-egui/src/last_session.rs new file mode 100644 index 000000000..bf64ea7ab --- /dev/null +++ b/crates/ui-egui/src/last_session.rs @@ -0,0 +1,159 @@ +//! Reopen the files from the last session (Preferences ▸ Documents and view, #442): which files +//! were open when PdfCraft last closed, the page each showed and the active tab. Local only, like +//! the recent-files list, and kept only while the preference is on. + +use serde::Serialize; + +use crate::PdfCraftApp; + +/// More files than anyone keeps open. Settings are untrusted, so a longer list is cut here. +pub const MAX_FILES: usize = 64; + +/// The files open when PdfCraft last closed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct LastSession { + pub files: Vec, + /// The path of the tab that was active. + pub active: Option, +} + +/// One open file and where the reader was in it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SessionFile { + /// Absolute, so a file opened from the command line by a relative path is found again. + pub path: String, + /// The page the tab showed (0-based). + pub page: usize, +} + +impl LastSession { + /// Read what [`PdfCraftApp::persist`] wrote. Malformed entries are skipped and the list is + /// capped at [`MAX_FILES`]. + pub(crate) fn from_json(v: &serde_json::Value) -> Self { + let files = v["files"] + .as_array() + .map(|files| { + files + .iter() + .filter_map(|f| { + let path = f["path"].as_str().filter(|p| !p.is_empty())?.to_string(); + // `go_to_page` clamps a page past the end. + let page = f["page"].as_u64().and_then(|p| usize::try_from(p).ok()).unwrap_or(0); + Some(SessionFile { path, page }) + }) + .take(MAX_FILES) + .collect() + }) + .unwrap_or_default(); + Self { files, active: v["active"].as_str().map(str::to_string) } + } +} + +/// `path` made absolute against the current directory, without resolving links (and without +/// Windows' `\\?\` prefix, which `canonicalize` adds). +#[cfg(not(target_arch = "wasm32"))] +fn absolute(path: &str) -> String { + std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) +} + +impl PdfCraftApp { + /// The files open now. Documents that were never saved to a file are left out (crash + /// recovery keeps their unsaved work), and so is everything on the web, which has no paths. + pub(crate) fn open_session(&self) -> LastSession { + #[cfg(not(target_arch = "wasm32"))] + { + let path = |v: &crate::DocView| self.session.get(v.id).and_then(|d| d.path.as_deref()).map(absolute); + let files = self.views.iter().filter_map(|v| Some(SessionFile { path: path(v)?, page: v.current })).take(MAX_FILES).collect(); + let active = self.active.and_then(|i| self.views.get(i)).and_then(path); + LastSession { files, active } + } + #[cfg(target_arch = "wasm32")] + LastSession::default() + } + + /// What to save as the last session: the files open when the quit began, once the app is + /// really quitting (unsaved tabs close one by one before that), else the files open now. + pub(crate) fn session_to_save(&self) -> LastSession { + match &self.quit_session { + Some(s) if self.allow_quit => s.clone(), + _ => self.open_session(), + } + } + + /// A quit is about to close its first tab: remember everything that is still open. + pub(crate) fn note_quit_session(&mut self) { + if self.quit_session.is_none() { + self.quit_session = Some(self.open_session()); + } + } + + /// The quit was cancelled: what is open from now on is the session again. + pub(crate) fn forget_quit_session(&mut self) { + self.quit_session = None; + } + + /// At startup, with the preference on: reopen the last session's files at the pages they + /// showed and bring its active tab forward. Files that are gone are skipped, and so are + /// `skip` (files the app is about to open anyway, e.g. from the command line) and documents + /// the Recovery dialog offers, so neither opens twice. + /// + /// Only one password prompt can wait at a time: the first encrypted file asks for its + /// password and later ones stay closed (they are in the recent-files list). + pub fn reopen_last_files(&mut self, skip: &[String]) { + let last = std::mem::take(&mut self.last_session); + #[cfg(not(target_arch = "wasm32"))] + if self.reopen_last_session { + let skip: Vec = + skip.iter().map(|p| absolute(p)).chain(self.recoverable.iter().filter_map(|m| m.path.as_deref().map(absolute))).collect(); + let mut active = None; + let mut prompt = self.password_prompt.take(); + for f in &last.files { + let open = |app: &Self| { + app.views.iter().any(|v| app.session.get(v.id).and_then(|d| d.path.as_deref()).map(absolute).as_deref() == Some(f.path.as_str())) + }; + if skip.contains(&f.path) || !std::path::Path::new(&f.path).is_file() || open(self) { + continue; + } + let before = self.views.len(); + self.open_path(&f.path); + if let Some(p) = self.password_prompt.take() { + prompt.get_or_insert(p); + } + if self.views.len() > before + && let Some(view) = self.views.last_mut() + { + view.go_to_page(f.page); + if last.active.as_ref() == Some(&f.path) { + active = Some(self.views.len() - 1); + } + } + } + self.password_prompt = prompt; + if active.is_some() { + self.active = active; + } + } + #[cfg(target_arch = "wasm32")] + let _ = (last, skip); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_sessions_are_skipped_and_capped() { + let v = serde_json::json!({ + "files": [{"path": 5}, {"path": ""}, "x", {"path": "/a.pdf", "page": -3}, {"path": "/b.pdf", "page": 1e300}, {"path": "/c.pdf", "page": 7}], + "active": 3, + }); + let s = LastSession::from_json(&v); + let pages: Vec<(&str, usize)> = s.files.iter().map(|f| (f.path.as_str(), f.page)).collect(); + assert_eq!(pages, [("/a.pdf", 0), ("/b.pdf", 0), ("/c.pdf", 7)]); + assert_eq!(s.active, None); + let many: Vec = (0..1000).map(|i| serde_json::json!({"path": format!("/{i}.pdf")})).collect(); + assert_eq!(LastSession::from_json(&serde_json::json!({"files": many})).files.len(), MAX_FILES); + assert_eq!(LastSession::from_json(&serde_json::json!("garbage")), LastSession::default()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3770168b0..c70cdc125 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,6 +72,7 @@ pub mod forms_ui; mod home; mod icon_data; pub mod icons; +pub mod last_session; mod pageboxes; mod palette; mod panels; @@ -377,6 +378,13 @@ pub struct PdfCraftApp { pub palette_query: String, pub all_tools_expanded: bool, pub recent: Vec, + /// Preferences: reopen the files that were open when PdfCraft last closed (#442). + pub reopen_last_session: bool, + /// The files open when PdfCraft last closed, read from the settings for + /// [`PdfCraftApp::reopen_last_files`]. + pub last_session: last_session::LastSession, + /// Quitting closes unsaved tabs one by one: what was open when the quit began. + quit_session: Option, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, pub toast: Option<(String, f64)>, @@ -619,6 +627,9 @@ impl PdfCraftApp { palette_query: String::new(), all_tools_expanded: false, recent: Vec::new(), + reopen_last_session: false, + last_session: Default::default(), + quit_session: None, pinned: Default::default(), toast: None, integrated_titlebar: false, @@ -1186,6 +1197,9 @@ impl PdfCraftApp { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); serde_json::json!({ "recent": self.recent, + "reopen_last_session": self.reopen_last_session, + // Kept only while the preference is on. + "last_session": self.reopen_last_session.then(|| self.session_to_save()), "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "default_mode": self.default_mode, @@ -1221,6 +1235,10 @@ impl PdfCraftApp { let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); self.recent = r; } + if let Some(on) = v["reopen_last_session"].as_bool() { + self.reopen_last_session = on; + } + self.last_session = last_session::LastSession::from_json(&v["last_session"]); self.pinned.restore(&v["pinned_folders"]); if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) { self.set_theme_preference(preference); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 524e42dec..8225ecf3c 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -388,6 +388,119 @@ fn quitting_with_unsaved_changes_asks_for_each_document() { assert!(h.state().close_request.is_none()); } +/// PDFs written to a fresh temp folder, removed when the test ends. +struct TempPdfs(std::path::PathBuf); + +impl TempPdfs { + fn new(test: &str) -> Self { + let dir = std::env::temp_dir().join(format!("pdfcraft-session-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn file(&self, name: &str, pages: usize) -> String { + let path = self.0.join(name); + std::fs::write(&path, fixture(pages)).unwrap(); + path.to_string_lossy().into_owned() + } +} + +impl Drop for TempPdfs { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// The app with Preferences ▸ Reopen the files that were open when PdfCraft last closed on. +fn session_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.reopen_last_session = true; + setup(&mut app); + app + }); + h.run_steps(4); + h +} + +fn open_paths(app: &PdfCraftApp) -> Vec { + app.views.iter().map(|v| app.session.get(v.id).and_then(|d| d.path.clone()).unwrap_or_default()).collect() +} + +#[test] +fn the_last_session_reopens_its_files_pages_and_active_tab() { + let dir = TempPdfs::new("reopen"); + let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 5), dir.file("c.pdf", 2)]; + let opened = files.clone(); + let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f))); + h.state_mut().views[1].go_to_page(3); + h.state_mut().active = Some(1); + h.run_steps(4); + assert_eq!(h.state().views[1].current, 3, "b.pdf shows page 4"); + let settings = h.state().persist(); + let mut h = session_harness(move |app| { + app.restore(&settings); + app.reopen_last_files(&[]); + }); + h.run_steps(4); + let app = h.state(); + assert_eq!(open_paths(app), files, "the same files, in the same order"); + assert_eq!(app.active, Some(1), "b.pdf is in front again"); + assert_eq!(app.views[1].current, 3, "at the page it showed"); +} + +#[test] +fn quitting_with_unsaved_changes_still_remembers_every_file() { + let dir = TempPdfs::new("quit"); + let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 1)]; + let opened = files.clone(); + let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f))); + for tab in 0..2 { + h.state_mut().active = Some(tab); + h.state_mut().views[tab].select_pages(&[0]); + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + } + h.state_mut().active = Some(1); + h.state_mut().close_request = Some(CloseRequest::Quit); + h.run_steps(3); + // Each unsaved tab closes as it is answered, before PdfCraft quits. + for _ in 0..2 { + h.get_by_label("Don't save").click(); + h.run_steps(3); + } + assert!(h.state().views.is_empty()); + let mut app = PdfCraftApp::new(); + app.restore(&h.state().persist()); + app.reopen_last_files(&[]); + assert_eq!(open_paths(&app), files, "both files come back"); + assert_eq!(app.active, Some(1), "with b.pdf in front, as when the quit began"); +} + +#[test] +fn the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files() { + let dir = TempPdfs::new("skip"); + let (a, b) = (dir.file("a.pdf", 1), dir.file("b.pdf", 1)); + let mut app = PdfCraftApp::new(); + app.open_path(&a); + app.open_path(&b); + let off: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert!(off["last_session"].is_null(), "nothing is kept while the preference is off"); + app.reopen_last_session = true; + let settings = app.persist(); + let mut later = PdfCraftApp::new(); + later.restore(&settings); + later.reopen_last_session = false; + later.reopen_last_files(&[]); + assert!(later.views.is_empty(), "turned off again: nothing reopens"); + // b.pdf is gone, and a.pdf is about to open from the command line. + std::fs::remove_file(&b).unwrap(); + let mut later = PdfCraftApp::new(); + later.restore(&settings); + later.reopen_last_files(&[a]); + assert!(later.views.is_empty(), "neither opens from the session"); +} + #[test] fn save_prompt_stays_inside_the_screen_for_a_long_filename() { // Issue #161: an unwrapped title carrying a long filename widened the centered modal past diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7da0de8f7..19698c151 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -661,6 +661,18 @@ tools = [] evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty"] notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it" +[[feature]] +id = "core.reopen-last-session" +title = "Reopen the files from the last session at startup" +area = "A" +tier = "P2" +milestone = "M3" +status = "shipped" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] +notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." + [[feature]] id = "core.usage-rights-preserve" title = "Preserve usage-rights (UR3) signatures from Reader-extended files"