From 95a769eedc44bcfb33a18e95b9c61b49e41bfd61 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 22:55:19 -0300 Subject: [PATCH 1/8] Open: opening an already-open file focuses its tab (#1620) Co-authored-by: GLM --- crates/ui-egui/src/last_session.rs | 2 +- crates/ui-egui/src/lib.rs | 16 ++++++++++++++++ crates/ui-egui/tests/editing.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/last_session.rs b/crates/ui-egui/src/last_session.rs index bf64ea7ab..7bc4833c0 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 91d534802..f34fd63de 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1208,8 +1208,24 @@ impl PdfCraftApp { } } + /// The tab already showing the file at `path`, if any: its position in `views`. Paths are + /// compared after `absolute` normalization, so another spelling of the same file matches + /// (#1620). + #[cfg(not(target_arch = "wasm32"))] + fn view_showing(&self, path: &str) -> Option { + let path = crate::last_session::absolute(path); + self.views + .iter() + .position(|v| self.session.get(v.id).and_then(|d| d.path.as_deref()).map(crate::last_session::absolute).as_deref() == Some(path.as_str())) + } + #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) { + if let Some(i) = self.view_showing(path) { + self.startup_superseded = true; + self.active = Some(i); + return; + } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); match std::fs::read(path) { Ok(bytes) => { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 17990d2fb..6a177831d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -653,6 +653,36 @@ 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); + let mut h = session_harness(|_| {}); + h.state_mut().open_path(&a); + h.state_mut().open_path(&b); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "two different files open two tabs"); + h.state_mut().open_path(&a); + 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_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(|_| {}); + 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 save_prompt_stays_inside_the_screen_for_a_long_filename() { // Issue #161: an unwrapped title carrying a long filename widened the centered modal past From a5e16bf556896f9d6ae435f18f29feccf7929c4a Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:12:13 -0300 Subject: [PATCH 2/8] Open Recent: focus an already-open file through the shared lookup (#1620) Co-authored-by: GLM --- crates/ui-egui/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f34fd63de..d7f33abbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1200,11 +1200,13 @@ impl PdfCraftApp { /// Open a file from a recent list: focus the tab already showing it, else open it (File ▸ /// Open Recent and the Home view's list share this). pub fn open_recent(&mut self, path: &str) { - if let Some(i) = self.views.iter().position(|v| self.session.get(v.id).and_then(|d| d.path.as_deref()) == Some(path)) { - self.active = Some(i); - } else { - #[cfg(not(target_arch = "wasm32"))] - self.open_path(path); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(i) = self.view_showing(path) { + self.active = Some(i); + } else { + self.open_path(path); + } } } From a7160cf45c7291fdecf46c39e0b8a554858ea3f6 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:21:23 -0300 Subject: [PATCH 3/8] Drop: dropping an already-open document focuses its tab (#1620) Co-authored-by: GLM --- crates/ui-egui/tests/ui.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 38cde809b..1e2df54bc 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -616,6 +616,29 @@ fn dropping_a_pdf_on_the_window_opens_it() { h.get_by_label_contains("dropped.pdf"); } +#[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()); + h.run_steps(3); + 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 From c7fbcd3fb68c81f468d3244ce583d68b42cc8185 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:36:55 -0300 Subject: [PATCH 4/8] Open: focus edge cases - vanished file, encrypted prompt, pathless documents (#1620) Co-authored-by: GLM --- crates/ui-egui/tests/editing.rs | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 6a177831d..215eaedc5 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -683,6 +683,51 @@ fn opening_the_same_file_with_a_dot_component_focuses_its_tab() { 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); + h.run_steps(2); + 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(|_| {}); + 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 From 09e32698859cb98690eef89a7f8ea09ced6a33b3 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:44:33 -0300 Subject: [PATCH 5/8] Parity: an already-open file focuses its tab on every open path (#1620) Co-authored-by: GLM --- parity/acrobat-features.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e57815882..56a116286 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -22,8 +22,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"] -notes = "File ▸ Open, the command line (Linux and Windows file associations), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type)" +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/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), 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)" [[feature]] id = "core.repair-broken-files" @@ -646,8 +646,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 dropped document that is already open focuses its tab (#1620)" [[feature]] id = "core.recent-files" @@ -659,7 +659,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" From 305a5676ce67a90c84c2afaf872d03d5adafbba1 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Tue, 13 Oct 2026 00:25:37 -0300 Subject: [PATCH 6/8] Open: programmatic callers see focus vs open vs failure (#1620) Final-review fixes for the focus-existing-tab branch: - open_path returns OpenOutcome (Opened | Focused | Failed), so callers no longer infer success by counting views. Toasts are unchanged; the value is the programmatic contract. - The control channel's ui open replies Ok with "focused": true when the file's tab was already open (an agent driving the app headlessly read the old Err as a read failure), and Err only on Failed. - open_then_run runs the picked tool on Focused too: picking an already-open file focuses its tab and the command runs, as before the branch. - open_recent silences the wasm-only unused `path` binding, like reopen_last_files. Co-authored-by: GLM --- crates/ui-egui/src/control.rs | 13 +++++------ crates/ui-egui/src/lib.rs | 40 +++++++++++++++++++++++++------- crates/ui-egui/src/pickers.rs | 4 +++- crates/ui-egui/tests/commands.rs | 27 +++++++++++++++++++++ crates/ui-egui/tests/control.rs | 35 ++++++++++++++++++++++++++++ crates/ui-egui/tests/editing.rs | 30 +++++++++++++++++++----- 6 files changed, 126 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bba7108c0..5f43f6c7f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -33,7 +33,8 @@ //! 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}`: open a file. +//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it +//! (`"focused": true`) instead of adding a tab; only a failed open replies `Err`. //! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect. use std::collections::{HashMap, VecDeque}; @@ -735,12 +736,10 @@ 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 { - 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::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/lib.rs b/crates/ui-egui/src/lib.rs index d7f33abbc..afb19f5e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -734,6 +734,19 @@ 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, 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 could not be read or opened; the user was told. + Failed, +} + impl PdfCraftApp { pub fn new() -> Self { Self { @@ -1208,6 +1221,8 @@ impl PdfCraftApp { self.open_path(path); } } + #[cfg(target_arch = "wasm32")] + let _ = path; } /// The tab already showing the file at `path`, if any: its position in `views`. Paths are @@ -1222,20 +1237,25 @@ impl PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - pub fn open_path(&mut self, path: &str) { + pub fn open_path(&mut self, path: &str) -> OpenOutcome { if let Some(i) = self.view_showing(path) { self.startup_superseded = true; self.active = Some(i); - return; + return OpenOutcome::Focused; } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); match std::fs::read(path) { - Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) { + Ok(bytes) => match self.open_bytes(&name, Some(path.to_string()), bytes) { + Ok(()) => OpenOutcome::Opened, + Err(e) => { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + OpenOutcome::Failed } + }, + Err(e) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + OpenOutcome::Failed } - Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } @@ -1437,12 +1457,12 @@ impl PdfCraftApp { let command = spec.id; self.ask_one(pickers::Ask::File(dialog), None, move |app, path| { let path = path.to_string_lossy().into_owned(); - let before = app.views.len(); - app.open_path(&path); + let outcome = app.open_path(&path); if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { // Encrypted: the tool runs once the password is in. prompt.then = Some(command); - } else if app.views.len() > before && app.active.is_some() { + } else if outcome != OpenOutcome::Failed { + // A new tab or an already-open one the pick focused: either way, run on it. app.execute(command); } }); @@ -2225,7 +2245,9 @@ impl eframe::App for PdfCraftApp { for e in os_events { match e { #[cfg(not(target_arch = "wasm32"))] - OsEvent::Open(paths) => paths.iter().for_each(|p| self.open_path(p)), + OsEvent::Open(paths) => paths.iter().for_each(|p| { + self.open_path(p); + }), #[cfg(target_arch = "wasm32")] OsEvent::Open(_) => {} // Like closing the window: `guard_quit` asks about unsaved changes. diff --git a/crates/ui-egui/src/pickers.rs b/crates/ui-egui/src/pickers.rs index dcb012089..39b38d645 100644 --- a/crates/ui-egui/src/pickers.rs +++ b/crates/ui-egui/src/pickers.rs @@ -151,7 +151,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 48a18eb42..8674c8810 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -187,6 +187,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 180dc85e6..7f1d27a76 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1427,3 +1427,38 @@ fn read_mode_retains_existing_rail_click_to_close_policy() { assert_eq!(h.state().right, None); 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(); + *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); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 215eaedc5..9146ae65d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -6,7 +6,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 { @@ -585,7 +585,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); @@ -607,7 +611,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]); @@ -659,17 +667,27 @@ fn opening_the_same_path_again_focuses_its_tab() { let a = pdfs.file("a.pdf", 1); let b = pdfs.file("b.pdf", 1); let mut h = session_harness(|_| {}); - h.state_mut().open_path(&a); - h.state_mut().open_path(&b); + 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"); - h.state_mut().open_path(&a); + 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"); From 6840b0fc32074d0f6050ec0594b71531189d1e93 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 19 Oct 2026 19:21:55 -0300 Subject: [PATCH 7/8] Tests: wait for the vanished file's tab before deleting it (#1620) The harness window is live, so open_path hands the read to the background worker and run_steps(2) could not promise the worker had finished: under load the file could be deleted before it was read, failing the test with "no second tab". wait_until polls for the tab the way the drop tests already do. Proven by delaying the worker 300 ms: the old test fails at views.len() == 0, this one passes. --- crates/ui-egui/tests/editing.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 35e7e671e..4cbae61fb 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -874,7 +874,9 @@ fn opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab() { let a = pdfs.file("a.pdf", 1); let mut h = session_harness(|_| {}); h.state_mut().open_path(&a); - h.run_steps(2); + // 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); From b1759657663b88a6b32193c9c53119210842b8fe Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 19 Oct 2026 19:21:59 -0300 Subject: [PATCH 8/8] Parity: limit the drop focus claim to path-bearing drops (#1620) Byte-only drops (no filesystem path) have nothing for tab_showing to match and open another tab, pinned by opening_documents_without_a_path_still_open_twice; the drag-drop-open note claimed focus for "a dropped document that is already open" without that limit. --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6324720bd..05ba1bc86 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -671,7 +671,7 @@ status = "shipped" commands = [] tools = [] 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 dropped document that is already open focuses its tab (#1620)" +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"