mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:23:02 +00:00
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:
@@ -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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user