collective

Browse / github.com/storytold/pdfcraft / patch

feat(ui): pin individual files to Home (#4772)

Pin individual files to Home I found the “Star” feature for files on the Home page in Acrobat very useful, as it allowed me to quickly open frequently used PDF files. I would really appreciate it if this feature could be implemented here as well. Thank you

Status
open
Score
0.00
Adopters
0 other operators
Author
YusufMntm (imported from pull request #4885, open)
Base
f94668f1c8c78c8cf024a2b29375336607a1f76d
Size
+143 −15 in 7 files
Review
pending
Shared
2026-10-28
Id
dda53fcf4f1f17b053bae2da

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/commands.rs crates/ui-egui/src/home.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/tests/it/commands.rs crates/ui-egui/tests/it/ui.rs parity/acrobat-features.toml

diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs--- a/crates/ui-egui/src/commands.rs+++ b/crates/ui-egui/src/commands.rs@@ -112,6 +112,7 @@ impl PdfCraftApp {         }     } +    /// Run `id` against the current state (called behind [`Self::execute`]'s `catch_unwind` guard).     fn execute_unguarded(&mut self, id: &str) -> bool {         let Some(spec) = commands::command(id) else { return false };         if !self.command_enabled(spec) {@@ -169,7 +170,7 @@ impl PdfCraftApp {                 None => self.notify_tr("No recent files"),             },             "file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"),-            "file.clear_recent" => self.recent.clear(),+            "file.clear_recent" => self.recent.retain(|r| r.pinned),             "file.pin_folder" => self.pin_folder_dialog(),             "page.combine" => self.open_combine_tab(),             "file.save" => {diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs--- a/crates/ui-egui/src/home.rs+++ b/crates/ui-egui/src/home.rs@@ -154,6 +154,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {             }             let mut open = None;             let mut remove = None;+            let mut pin = None;             for r in &app.recent {                 let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());                 resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name));@@ -166,6 +167,26 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {                 } else {                     press.wash(ui, rect, 8, false);                 }+                // Pin or unpin this file so it stays at the top of Recent across Clear (#4772).+                // The interaction is always present so keyboard users can focus and activate it;+                // the icon is drawn when the row is hovered, focused, or already pinned.+                let pin_rect = Rect::from_center_size(rect.right_center() - vec2(58.0, 0.0), vec2(28.0, 28.0));+                let p = ui.interact(pin_rect, ui.scope_id().with(("pin-recent", &r.path)), Sense::click());+                let (label, hover) = if r.pinned {+                    (crate::i18n::fmt(tl!("Unpin {name} from Recent"), &[("name", &r.name)]), tl!("Unpin from Recent"))+                } else {+                    (crate::i18n::fmt(tl!("Pin {name} to Recent"), &[("name", &r.name)]), tl!("Pin to Recent"))+                };+                p.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));+                if hovered || r.pinned || p.has_focus() {+                    if p.hovered() || p.has_focus() {+                        ui.painter().rect_filled(pin_rect, CornerRadius::same(6), t.pressed);+                    }+                    icons::paint(ui, pin_rect.shrink(6.0), "pin", 16.0, if r.pinned { t.accent_text } else { t.icon });+                }+                if p.on_hover_text(hover).clicked() {+                    pin = Some(r.path.clone());+                }                 if hovered {                     // Remove just this file from the list (#430); the file itself is untouched.                     let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0));@@ -188,9 +209,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {                     22.0,                     egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),                 );-                // Room on the right for the remove button.+                // Room on the right for the pin and remove buttons.                 let detail = ui.painter().text(-                    body.right_center() - vec2(48.0, 0.0),+                    body.right_center() - vec2(80.0, 0.0),                     Align2::RIGHT_CENTER,                     format!("{}  ·  {}", crate::i18n::trn(crate::i18n::current(), r.pages as u64, "{n} page", "{n} pages"), human_size(r.size)),                     theme::regular(12.0),@@ -207,6 +228,12 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {             if let Some(p) = open {                 app.open_recent(&p);             }+            if let Some(p) = pin {+                if let Some(r) = app.recent.iter_mut().find(|r| r.path == p) {+                    r.pinned = !r.pinned;+                }+                app.recent.sort_by_key(|r| !r.pinned);+            }             if let Some(p) = remove {                 app.recent.retain(|r| r.path != p);             }@@ -227,6 +254,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { mod tests {     use super::*; +    /// Verify that translated tool card descriptions wrap without overlapping the action label.     #[test]     fn translated_card_description_leaves_room_for_the_action() {         let de = crate::i18n::Lang::from_code("de").unwrap();diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -612,17 +612,21 @@ pub struct UnlockPrompt { }  impl UnlockPrompt {+    /// Create a prompt asking for `doc`'s permissions password before running `then`.     pub(crate) fn new(doc: &pdfcraft_engine::Document, then: Option<&'static str>) -> Self {         Self { doc: doc.id, name: doc.name.clone(), input: String::new(), error: None, then }     } } +/// A file in the Recent list, with its path, page count, byte size and whether it is pinned. #[derive(serde::Serialize, serde::Deserialize)] pub struct RecentFile {     pub name: String,     pub path: String,     pub pages: usize,     pub size: usize,+    #[serde(default)]+    pub pinned: bool, }  pub struct PdfCraftApp {@@ -1529,6 +1533,7 @@ impl PdfCraftApp {         }     } +    /// Prepare `bytes` with `password` (or open a password prompt if needed) and commit the tab.     fn try_open(         &mut self,         name: &str,@@ -1564,6 +1569,7 @@ impl PdfCraftApp {         self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ())     } +    /// Commit a prepared document to the session, open its view tab and record it in Recent.     fn finish_open(         &mut self,         name: &str,@@ -1638,9 +1644,7 @@ impl PdfCraftApp {         if let Some(p) = path {             #[cfg(target_os = "macos")]             macos_association::repair(std::path::Path::new(&p));-            self.recent.retain(|r| r.path != p);-            self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });-            self.recent.truncate(12);+            self.add_recent(name, p, pages, size);         }         // What the form's scripts said while it opened (messages, errors) shows now, not with         // the next edit.@@ -1653,6 +1657,17 @@ impl PdfCraftApp {         Ok(id)     } +    /// Record `path` at the head of the Recent list, preserving its pinned state and keeping+    /// pinned files at the top and within the 12-file cap.+    pub(crate) fn add_recent(&mut self, name: &str, path: String, pages: usize, size: usize) {+        let pinned = self.recent.iter().find(|r| same_file(&r.path, &path)).is_some_and(|r| r.pinned);+        self.recent.retain(|r| !same_file(&r.path, &path));+        self.recent.insert(0, RecentFile { name: name.to_string(), path, pages, size, pinned });+        self.recent.sort_by_key(|r| !r.pinned);+        self.recent.truncate(12);+    }++    /// Open a file dropped onto the application window, by path when absolute or by its bytes.     #[cfg(not(target_arch = "wasm32"))]     fn open_dropped(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) {         let p = f.path().to_string_lossy().into_owned();@@ -2721,9 +2736,13 @@ impl PdfCraftApp {             self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end());         }         if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(v["recent"].clone()) {-            // Only keep entries whose files still exist.+            // Keep pinned entries (whose volume or synced drive may be offline at startup) and+            // unpinned entries whose files still exist, sorted with pinned files first.             #[cfg(not(target_arch = "wasm32"))]-            let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();+            let mut r: Vec<RecentFile> = r.into_iter().filter(|f| f.pinned || std::path::Path::new(&f.path).exists()).collect();+            #[cfg(target_arch = "wasm32")]+            let mut r = r;+            r.sort_by_key(|f| !f.pinned);             self.recent = r;         }         if let Some(on) = v["reopen_last_session"].as_bool() {diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -706,6 +706,7 @@ mod tests {         assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true));     } +    /// Verify that an open modal dialog disables document menu commands while leaving Quit enabled.     #[test]     fn a_dialog_greys_document_commands_and_drops_their_clicks() {         let (mut app, ctx) = app(&["a.pdf"]);@@ -721,10 +722,13 @@ mod tests {         assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command()));     } +    /// Verify that Open Recent and Window list each recent file and open document once in order.     #[test]     fn recent_files_and_open_documents_are_listed_once_each() {         let (mut app, ctx) = app(&["a.pdf", "b.pdf"]);-        app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into();+        app.recent = ["one.pdf", "two.pdf"]+            .map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0, pinned: false })+            .into();         let bar = linux(&app, &ctx);         for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() {             assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name);diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs--- a/crates/ui-egui/tests/it/commands.rs+++ b/crates/ui-egui/tests/it/commands.rs@@ -321,7 +321,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() {             let mut app = PdfCraftApp::new();             app.set_option("language", "en").unwrap();             app.open_bytes("doc.pdf", None, fixture(3)).unwrap();-            app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0 });+            app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0, pinned: false });             app         }     });@@ -351,8 +351,9 @@ fn the_open_recent_menu_is_disabled_while_the_list_is_empty() {     ); } +/// Build a synthetic unpinned `RecentFile` entry for menu and command tests. fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile {-    pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 }+    pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0, pinned: false } }  /// Check that Clear Recent Files empties the list and disables Open Recent.@@ -376,6 +377,7 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() {     assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } +/// Verify that `file.clear_recent` clears unpinned entries, persists, and reports an empty list. #[test] fn clear_recent_files_runs_from_the_palette_and_is_saved() {     let mut app = PdfCraftApp::new();diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs--- a/crates/ui-egui/tests/it/ui.rs+++ b/crates/ui-egui/tests/it/ui.rs@@ -39,6 +39,7 @@ fn home_shows_welcome_and_tools() {     h.get_by_label("Open file"); } +/// Verify that changing the tools panel side in Preferences moves the pane and persists. #[test] fn tools_panel_side_preference_moves_and_persists() {     use pdfcraft_ui_egui::Dialog;@@ -72,13 +73,14 @@ fn painted_text(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, bool, egui::R         .collect() } +/// Verify that long recent file names and paths truncate before colliding with the page count. #[test] fn long_recent_names_and_paths_are_cut_before_the_page_count() {     let name = format!("{}.pdf", "Quarterly report final version ".repeat(10));     let path = format!("/{}/{name}", "A folder with a long name".repeat(8));     let h = harness({         let (name, path) = (name.clone(), path.clone());-        move |app| app.recent.push(pdfcraft_ui_egui::RecentFile { name, path, pages: 7, size: 2048 })+        move |app| app.recent.push(pdfcraft_ui_egui::RecentFile { name, path, pages: 7, size: 2048, pinned: false })     });     let text = painted_text(&h);     let detail = text.iter().find(|(s, ..)| s.starts_with("7 pages")).expect("the row shows its page count").2;@@ -89,6 +91,7 @@ fn long_recent_names_and_paths_are_cut_before_the_page_count() {     } } +/// Verify that removing a single recent file or clearing all unpinned recent files updates Home. #[test] fn home_removes_one_recent_file_or_clears_them_all() {     // Tall enough that the Recent list is on screen without scrolling.@@ -96,7 +99,7 @@ fn home_removes_one_recent_file_or_clears_them_all() {         let mut app = PdfCraftApp::new();         app.set_option("language", "en").unwrap();         for name in ["first.pdf", "second.pdf", "third.pdf"] {-            app.recent.push(pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 });+            app.recent.push(pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0, pinned: false });         }         app     });@@ -115,6 +118,76 @@ fn home_removes_one_recent_file_or_clears_them_all() {     assert!(h.query_by_label("Clear Recent Files").is_none(), "and goes away with nothing left to clear"); } +/// Pinning a recent file moves it to the top of Recent, keeps it across Clear Recent Files and+/// new opens, and persists its pinned state across a restart (#4772).+#[test]+fn home_pins_recent_file_to_top_keeps_it_across_clear_and_persists() {+    let dir = std::env::temp_dir().join(format!("pdfcraft-pin-recent-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).unwrap();+    let files: Vec<(String, String)> = ["first.pdf", "second.pdf", "third.pdf"]+        .into_iter()+        .map(|name| {+            let p = dir.join(name);+            std::fs::write(&p, FIXTURE).unwrap();+            (name.to_string(), p.to_string_lossy().into_owned())+        })+        .collect();+    let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe({+        let files = files.clone();+        move |_cc| {+            let mut app = PdfCraftApp::new();+            app.set_option("language", "en").unwrap();+            for (name, path) in files {+                app.recent.push(pdfcraft_ui_egui::RecentFile { name, path, pages: 1, size: FIXTURE.len(), pinned: false });+            }+            app+        }+    });+    h.run_steps(4);+    h.get_by_label("second.pdf").hover();+    h.run_steps(2);+    h.get_by_label("Pin second.pdf to Recent").click();+    h.run_steps(2);+    let names: Vec<String> = h.state().recent.iter().map(|r| r.name.clone()).collect();+    assert_eq!(names, ["second.pdf", "first.pdf", "third.pdf"], "pinning moves the file to the top of Recent");+    assert!(h.state().recent[0].pinned);++    // Opening more files (past the 12-file cap) and reopening the pinned file keeps it pinned at the top.+    for i in 0..14 {+        let extra = dir.join(format!("extra-{i}.pdf"));+        h.state_mut().open_bytes(&format!("extra-{i}.pdf"), Some(extra.to_string_lossy().into_owned()), FIXTURE.to_vec()).unwrap();+    }+    assert_eq!(h.state().recent.len(), 12);+    assert_eq!(h.state().recent[0].name, "second.pdf", "pinned file stays at the top past the 12-file cap");+    assert!(h.state().recent[0].pinned);+    h.state_mut().open_bytes("second.pdf", Some(files[1].1.clone()), FIXTURE.to_vec()).unwrap();+    assert!(h.state().recent[0].pinned, "reopening a pinned file preserves its pinned state");+    h.state_mut().active = None;+    h.run_steps(2);++    // Clear Recent Files removes unpinned files and keeps pinned ones.+    h.get_by_label("Clear Recent Files").click();+    h.run_steps(2);+    let remaining: Vec<(&str, bool)> = h.state().recent.iter().map(|r| (r.name.as_str(), r.pinned)).collect();+    assert_eq!(remaining, [("second.pdf", true)], "Clear Recent Files keeps pinned files");++    // Pinned state survives persistence round-trip, even when the file is temporarily missing at startup.+    let saved = h.state().persist();+    let _ = std::fs::remove_dir_all(&dir);+    let mut restarted = PdfCraftApp::new();+    restarted.restore(&saved);+    assert_eq!(restarted.recent.len(), 1);+    assert_eq!(restarted.recent[0].name, "second.pdf");+    assert!(restarted.recent[0].pinned, "pinned state survives persist and restore");++    // The unpin button stays reachable on a pinned row and unpins it when clicked.+    h.get_by_label("Unpin second.pdf from Recent").click();+    h.run_steps(2);+    assert!(!h.state().recent[0].pinned, "clicking Unpin clears the pinned state");+}++/// Verify that clicking View more in the sidebar lists every tool group in the catalog. #[test] fn every_catalog_tool_is_listed_after_view_more() {     let mut h = harness(|_| {});@@ -125,6 +198,7 @@ fn every_catalog_tool_is_listed_after_view_more() {     } } +/// Verify that expanding All tools shows every category and filters tools by search query. #[test] fn expanded_all_tools_lists_every_tool_by_category_and_searches() {     let mut h = harness(|_| {});diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -682,8 +682,8 @@ milestone = "M3" status = "shipped" commands = ["file.open_recent", "file.clear_recent"] tools = []-evidence = ["crates/ui-egui/tests/it/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/it/ui.rs::home_removes_one_recent_file_or_clears_them_all"]-notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it, from any open path (#1620); Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)"+evidence = ["crates/ui-egui/tests/it/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/it/ui.rs::home_removes_one_recent_file_or_clears_them_all", "crates/ui-egui/tests/it/ui.rs::home_pins_recent_file_to_top_keeps_it_across_clear_and_persists"]+notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it, from any open path (#1620); Clear Recent Files at the foot of Open Recent, on Home and in the palette, a remove button on each Home row (#430), and a pin button on each Home row that keeps pinned files at the top across new opens, Clear Recent Files and restarts (#4772); missing: the row's remove button shows on pointer hover only (keyboard users clear the unpinned list)"  [[feature]] id = "core.reopen-last-session"