Merge PR #1663: Open: opening an already-open document focuses its tab (#1620)

Conflicts:
- crates/ui-egui/src/lib.rs (OsEvent::Open): #2461's window raise kept, with the PR's open_path call that discards its outcome
This commit is contained in:
aiongg
2026-10-22 01:49:22 +00:00
9 changed files with 314 additions and 44 deletions
+8 -10
View File
@@ -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}"))),
}
}
+1 -1
View File
@@ -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())
}
+63 -24
View File
@@ -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));
+3 -1
View File
@@ -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),
});
}
+27
View File
@@ -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();
+83
View File
@@ -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<Mutex<Option<ControlClient>>> = 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<Mutex<Option<ControlClient>>> = 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<egui::Event>) {
h.input_mut().events.extend(events);
h.step();
+100 -3
View File
@@ -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<u8> {
@@ -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
+24
View File
@@ -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
+5 -5
View File
@@ -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"