diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bca9a7784a..debad45352 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -36,8 +36,9 @@ //! for this session without changing the saved preference. `ui.state.default_mode` reports it. //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. -//! - `ui.open {path}`: accept a file open; native opens report `pending: true` until they -//! complete and are listed in `ui.state.pending_opens`. +//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it +//! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until +//! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`. //! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect. use std::collections::{HashMap, VecDeque}; @@ -767,14 +768,11 @@ impl Host for crate::PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn open(&mut self, path: &str) -> Reply { - let before = self.views.len(); - self.open_path(path); - if self.views.len() > before || self.password_prompt.is_some() { - Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })) - } else if self.pending_opens.iter().any(|pending| pending.path.as_deref() == Some(path)) { - Ok(json!({ "accepted": path, "pending": true })) - } else { - Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))) + match self.open_path(path) { + crate::OpenOutcome::Opened => Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })), + crate::OpenOutcome::Focused => Ok(json!({ "opened": path, "focused": true })), + crate::OpenOutcome::Pending => Ok(json!({ "accepted": path, "pending": true })), + crate::OpenOutcome::Failed => Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))), } } diff --git a/crates/ui-egui/src/last_session.rs b/crates/ui-egui/src/last_session.rs index bf64ea7ab4..7bc4833c0a 100644 --- a/crates/ui-egui/src/last_session.rs +++ b/crates/ui-egui/src/last_session.rs @@ -52,7 +52,7 @@ impl LastSession { /// `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 { +pub(crate) fn absolute(path: &str) -> String { std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e49c45b5b9..287363c2dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -903,6 +903,23 @@ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> { Some(out) } +/// What [`PdfCraftApp::open_path`] did: opened the file in a new tab, focused the tab already +/// showing it, handed it to the background open worker, or failed. Failures tell the user +/// through a toast; this is the programmatic contract for callers that must know (the control +/// channel, tools that wait for a file) (#1620). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OpenOutcome { + /// The file opened in a new tab (or raised its password prompt). + Opened, + /// A tab already showed the file; it was focused and no tab was added. + Focused, + /// The file was accepted for opening in the background; its tab appears when the open + /// completes (`ui.state.pending_opens` lists the files on the way). + Pending, + /// The file could not be read or opened; the user was told. + Failed, +} + impl PdfCraftApp { pub fn new() -> Self { Self { @@ -1762,6 +1779,8 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] self.open_path(path); } + #[cfg(target_arch = "wasm32")] + let _ = path; } #[cfg(not(target_arch = "wasm32"))] @@ -1846,24 +1865,27 @@ impl PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - pub fn open_path(&mut self, path: &str) { - self.open_path_then(path, None); + pub fn open_path(&mut self, path: &str) -> OpenOutcome { + self.open_path_then(path, None) } - /// Keep a chosen tool with its file until preparation and any password entry finish. + /// Keep a chosen tool with its file until preparation and any password entry finish. The + /// return value is the programmatic open contract (#1620): callers that must know whether + /// the file opened, focused its tab, was handed to the background worker, or failed. #[cfg(not(target_arch = "wasm32"))] - fn open_path_then(&mut self, path: &str, then: Option<&'static str>) { + fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome { // One file, one tab (#2166): a file that is open already comes to the front instead, // and the tool it was opened for runs on it. One still being opened is left to that. if let Some(i) = self.tab_showing(path) { + self.startup_superseded = true; self.active = Some(i); if let Some(command) = then { self.execute(command); } - return; + return OpenOutcome::Focused; } if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) { - return; + return OpenOutcome::Pending; } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. @@ -1877,7 +1899,10 @@ impl PdfCraftApp { let before = self.views.len(); if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); - } else if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) { + return OpenOutcome::Failed; + } + if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) { + // Encrypted: the tool runs once the password is in. prompt.then = then; } else if self.views.len() > before { if let Some(doc) = self.views.last().map(|view| view.id) { @@ -1886,25 +1911,37 @@ impl PdfCraftApp { if let Some(command) = then { self.execute(command); } + } else { + // Open reported success but added no tab and raised no prompt; the + // control channel answers this as a failure to open. + return OpenOutcome::Failed; } + OpenOutcome::Opened + } + Err(error) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]); + OpenOutcome::Failed + } + } + } else { + self.startup_superseded = true; + match self.schedule_open(OpenRequest { + name: name.clone(), + path: Some(path.to_string()), + stamp, + input: open_worker::Input::Path(std::path::PathBuf::from(path)), + password: None, + activate: true, + after_open: None, + recovered: None, + then, + }) { + Ok(_) => OpenOutcome::Pending, + Err(error) => { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + OpenOutcome::Failed } - Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), } - return; - } - self.startup_superseded = true; - if let Err(error) = self.schedule_open(OpenRequest { - name: name.clone(), - path: Some(path.to_string()), - stamp, - input: open_worker::Input::Path(std::path::PathBuf::from(path)), - password: None, - activate: true, - after_open: None, - recovered: None, - then, - }) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); } } @@ -3138,7 +3175,9 @@ impl eframe::App for PdfCraftApp { match e { #[cfg(not(target_arch = "wasm32"))] OsEvent::Open(paths) => { - paths.iter().for_each(|p| self.open_path(p)); + paths.iter().for_each(|p| { + self.open_path(p); + }); // Files handed to the running app (Finder, or another launch): bring its // window to the front, also when minimized or full screen on another Space. ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); diff --git a/crates/ui-egui/src/pickers.rs b/crates/ui-egui/src/pickers.rs index 7661b71750..c499eb738a 100644 --- a/crates/ui-egui/src/pickers.rs +++ b/crates/ui-egui/src/pickers.rs @@ -163,7 +163,9 @@ impl PdfCraftApp { }; let ask = if multiple { Ask::Files(dialog) } else { Ask::File(dialog) }; self.ask(ask, target, move |app, paths| match pick_for { - PickFor::Open => paths.iter().for_each(|p| app.open_path(&p.to_string_lossy())), + PickFor::Open => paths.iter().for_each(|p| { + app.open_path(&p.to_string_lossy()); + }), PickFor::Files(purpose) => app.use_paths(purpose, &paths), }); } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 5afb4db558..8a5f699540 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -218,6 +218,33 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn a_tool_chosen_with_an_already_open_file_focuses_its_tab_and_still_runs() { + let dir = std::env::temp_dir().join(format!("pdfcraft-tool-focus-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("picked.pdf"); + std::fs::write(&path, fixture(2)).unwrap(); + let pick = path.to_string_lossy().into_owned(); + let picked = pick.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + // The file is already open, but the Home tab is showing (no active document). + app.open_path(&picked); + app.active = None; + app.pick_override = Some(vec![pick]); + app + }); + h.run_steps(2); + h.state_mut().run_command("export.text"); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1, "the existing tab is reused, not duplicated"); + assert_eq!(app.active, Some(0), "the pick focused the tab it already opened"); + assert!(matches!(app.dialog, Some(Dialog::Export(_))), "the tool still runs on it: {:?}", app.dialog); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn shortcuts_run_registered_commands() { let mut h = harness(); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 0600e80c77..e0004a6df2 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1560,6 +1560,89 @@ fn read_mode_retains_existing_rail_click_to_close_policy() { assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read); } +#[test] +fn control_open_of_an_already_open_file_replies_ok_and_focuses_its_tab() { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.pdf").to_string_lossy().into_owned(); + let b = dir.join("b.pdf").to_string_lossy().into_owned(); + std::fs::write(&a, fixture(2)).unwrap(); + std::fs::write(&b, fixture(3)).unwrap(); + let slot: Arc>> = Arc::default(); + let s = slot.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + // Inline opens: this test pins the synchronous open contract (focus vs open vs + // failure). Live apps schedule the worker instead and reply `pending` (#1620). + app.run_inline = true; + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + h.run_steps(4); + let c = slot.lock().unwrap().take().unwrap(); + + let first = ok(&mut h, &c, "ui.open", json!({ "path": a })); + assert_eq!(first["opened"], json!(a), "{first}"); + ok(&mut h, &c, "ui.open", json!({ "path": b })); + // The file is already open: the tab focuses, and the reply must be Ok — an agent driving the + // app headlessly would otherwise read a focus as a read failure (#1620). + let again = ok(&mut h, &c, "ui.open", json!({ "path": a })); + assert_eq!(again["focused"], true, "{again}"); + + let state = ok(&mut h, &c, "ui.state", json!({})); + let docs = state["documents"].as_array().cloned().unwrap_or_default(); + assert_eq!(docs.len(), 2, "opening an open file adds no tab: {state}"); + let a_tab = docs.iter().find(|d| d["path"] == json!(a)).unwrap(); + assert_eq!(a_tab["active"], true, "the already-open tab is in front: {state}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The other half of the #1620 contract on a live UI: a fresh open is scheduled on the +/// background worker and answered `pending` (upstream's reply shape), the tab appears once +/// the open completes, and opening the file again then focuses that tab. +#[test] +fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-pending-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("pending.pdf").to_string_lossy().into_owned(); + std::fs::write(&a, fixture(2)).unwrap(); + let slot: Arc>> = Arc::default(); + let s = slot.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + h.run_steps(4); + let c = slot.lock().unwrap().take().unwrap(); + + let first = ok(&mut h, &c, "ui.open", json!({ "path": a })); + assert_eq!(first["accepted"], json!(a), "{first}"); + assert_eq!(first["pending"], true, "{first}"); + + // The worker completes the open on later frames; the tab shows, and only then does a + // repeated open focus it instead of reporting another pending open. + let mut documents = 0; + for _ in 0..30 { + h.run_steps(1); + let state = ok(&mut h, &c, "ui.state", json!({})); + documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default(); + if documents == 1 { + break; + } + } + assert_eq!(documents, 1, "the pending open completed into a tab"); + + let again = ok(&mut h, &c, "ui.open", json!({ "path": a })); + assert_eq!(again["focused"], true, "{again}"); + let state = ok(&mut h, &c, "ui.state", json!({})); + let docs = state["documents"].as_array().cloned().unwrap_or_default(); + assert_eq!(docs.len(), 1, "focusing the completed open adds no tab: {state}"); + let _ = std::fs::remove_dir_all(&dir); +} + fn navigation_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec) { h.input_mut().events.extend(events); h.step(); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index daaf3f31b8..1e76eb2cfc 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -8,7 +8,7 @@ use egui::{Key, Modifiers}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; -use pdfcraft_ui_egui::{CloseRequest, Dialog, PdfCraftApp}; +use pdfcraft_ui_egui::{CloseRequest, Dialog, OpenOutcome, PdfCraftApp}; /// An `n`-page document with a proper xref table; page `i` shows "Page i+1". fn fixture(n: usize) -> Vec { @@ -834,7 +834,11 @@ 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))); + 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); @@ -856,7 +860,11 @@ 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))); + 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]); @@ -902,6 +910,95 @@ fn the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files() { assert!(later.views.is_empty(), "neither opens from the session"); } +#[test] +fn opening_the_same_path_again_focuses_its_tab() { + let pdfs = TempPdfs::new("dedup-open"); + let a = pdfs.file("a.pdf", 1); + let b = pdfs.file("b.pdf", 1); + // Inline opens: this pins the synchronous open contract (open vs focus). A live app + // schedules the background worker instead and answers `Pending` until it completes. + let mut h = session_harness(|app| app.run_inline = true); + assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Opened); + assert_eq!(h.state_mut().open_path(&b), OpenOutcome::Opened); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "two different files open two tabs"); + assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Focused, "opening an open file focuses, it doesn't reopen"); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "opening an open file again adds no tab"); + assert_eq!(h.state().active, Some(0), "the existing tab comes to the front"); + assert_eq!(open_paths(h.state()), vec![a, b], "the tab order is untouched"); +} + +#[test] +fn opening_a_missing_file_fails_and_tells_the_user() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let missing = std::env::temp_dir().join(format!("pdfcraft-no-such-file-{}.pdf", std::process::id())); + assert_eq!(app.open_path(&missing.to_string_lossy()), OpenOutcome::Failed); + assert!(app.views.is_empty()); + assert!(app.toast.is_some(), "the failure is still reported to the user"); +} + +#[test] +fn opening_the_same_file_with_a_dot_component_focuses_its_tab() { + let pdfs = TempPdfs::new("dedup-dot"); + let a = pdfs.file("a.pdf", 1); + let dotted = format!("{}/./a.pdf", pdfs.0.display()); + let mut h = session_harness(|app| app.run_inline = true); + h.state_mut().open_path(&a); + h.state_mut().open_path(&dotted); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1, "another spelling of the same file matches"); + assert_eq!(h.state().active, Some(0)); +} + +#[test] +fn opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab() { + let pdfs = TempPdfs::new("dedup-vanished"); + let a = pdfs.file("a.pdf", 1); + let mut h = session_harness(|_| {}); + h.state_mut().open_path(&a); + // A live window opens through the background worker; the tab has to exist before the file + // is deleted, or the worker itself fails to read what this test is about to remove. + support::wait_until(&mut h, "the vanished file's tab", |app| app.views.len() == 1); + std::fs::remove_file(&a).unwrap(); + h.state_mut().open_path(&a); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1, "no second tab"); + assert_eq!(h.state().active, Some(0), "the open tab is focused"); + assert!(h.state().toast.is_none(), "no read error: the file is never read again"); +} + +#[test] +fn opening_an_encrypted_document_twice_keeps_one_prompt_and_one_tab() { + let pdfs = TempPdfs::new("dedup-encrypted"); + let secret = pdfs.0.join("secret.pdf"); + std::fs::write(&secret, protected("pw", "owner", -1)).unwrap(); + let s = secret.to_string_lossy().into_owned(); + let mut h = session_harness(|app| app.run_inline = true); + h.state_mut().open_path(&s); + h.state_mut().open_path(&s); + h.run_steps(2); + assert!(h.state().views.is_empty(), "still locked"); + let field = h.get_by_role(Role::PasswordInput); + field.focus(); + field.type_text("pw"); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "unlocking opens one tab, not two"); +} + +#[test] +fn opening_documents_without_a_path_still_open_twice() { + let bytes = fixture(1); + let mut h = session_harness(|_| {}); + h.state_mut().open_bytes("copy.pdf", None, bytes.clone()).unwrap(); + h.state_mut().open_bytes("copy.pdf", None, bytes).unwrap(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "bytes have no path to compare (attachments, conversions)"); +} + #[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/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 6cde0d0ef6..e392a66144 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -834,6 +834,30 @@ fn dropping_a_pdf_on_the_window_opens_it() { assert_eq!(doc.bytes.as_slice(), FIXTURE); } +#[test] +fn dropping_an_open_pdf_again_focuses_its_tab() { + let dir = std::env::temp_dir().join(format!("pdfcraft-drop-dedup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("dropped.pdf"); + std::fs::write(&path, FIXTURE).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(3); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: path.clone(), bytes: FIXTURE.to_vec() }); + h.input_mut().dropped_files.push(file.clone()); + // The drop opens through the background worker on a live window; wait for the tab. + support::wait_until(&mut h, "dropped document", |app| app.views.len() == 1); + assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab"); + h.input_mut().dropped_files.push(file); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1, "dropping it again adds no tab"); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn a_file_dropped_on_the_page_grid_is_read_from_disk_and_inserted() { // The grid's drop reads a file from its path when its turn to be inserted comes, and inserts it diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a40ba8f963..5aaec51037 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -34,8 +34,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits"] -notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)" +evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] +notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)" [[feature]] id = "core.repair-broken-files" @@ -670,8 +670,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it"] -notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously" +evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it", "crates/ui-egui/tests/ui.rs::dropping_an_open_pdf_again_focuses_its_tab"] +notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously; a drop with a filesystem path matching an open document focuses that tab (#1620); pathless bytes have nothing to match and open another tab" [[feature]] id = "core.recent-files" @@ -683,7 +683,7 @@ status = "shipped" commands = ["file.open_recent", "file.clear_recent"] 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", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/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; 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)" +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)" [[feature]] id = "core.reopen-last-session"