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
@@ -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" => {@@ -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();@@ -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() {@@ -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);@@ -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();@@ -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(|_| {});@@ -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"