diff --git a/src-tauri/src/os_open.rs b/src-tauri/src/os_open.rs index cc32954..d1b045a 100644 --- a/src-tauri/src/os_open.rs +++ b/src-tauri/src/os_open.rs @@ -17,6 +17,27 @@ pub struct OpenedPathQueue { frontend_ready: bool, } +impl OpenedPathQueue { + fn enqueue(&mut self, paths: Vec) -> Option> { + let mut paths = dedupe_paths(paths); + if paths.is_empty() { + return None; + } + if self.frontend_ready { + return Some(paths); + } + + paths.retain(|path| !self.pending.contains(path)); + self.pending.extend(paths); + None + } + + fn take_pending(&mut self) -> Vec { + self.frontend_ready = true; + std::mem::take(&mut self.pending) + } +} + /// Skip argv[0], then map the rest through [`parse_opened_token`]. pub fn parse_opened_argv(args: impl IntoIterator>) -> Vec { let mut iter = args.into_iter(); @@ -69,11 +90,9 @@ pub fn enqueue_opened_paths(app: &AppHandle, paths: Vec) { Ok(g) => g, Err(_) => return, }; - if guard.frontend_ready { + if let Some(live_paths) = guard.enqueue(strings) { drop(guard); - let _ = app.emit(OPENED_PATHS_EVENT, strings); - } else { - guard.pending.extend(strings); + let _ = app.emit(OPENED_PATHS_EVENT, live_paths); } } @@ -93,8 +112,17 @@ pub fn take_opened_paths(queue: State<'_, Mutex>) -> Vec g, Err(poisoned) => poisoned.into_inner(), }; - guard.frontend_ready = true; - std::mem::take(&mut guard.pending) + guard.take_pending() +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut unique = Vec::with_capacity(paths.len()); + for path in paths { + if !unique.contains(&path) { + unique.push(path); + } + } + unique } fn strip_file_scheme(token: &str) -> Option<&str> { diff --git a/src-tauri/src/os_open_tests.rs b/src-tauri/src/os_open_tests.rs index aa10b75..aee864b 100644 --- a/src-tauri/src/os_open_tests.rs +++ b/src-tauri/src/os_open_tests.rs @@ -116,3 +116,38 @@ fn os_open_parse_utf8_cjk_argv() { "R-UTF8: argv CJK paths must survive parse_opened_argv", ); } + +#[test] +fn os_open_queue_preserves_order_and_deduplicates_pending_paths() { + let mut queue = crate::os_open::OpenedPathQueue::default(); + + assert_eq!( + queue.enqueue(vec![ + "/tmp/first file.pdf".into(), + "/tmp/second.pdf".into(), + "/tmp/first file.pdf".into(), + ]), + None, + ); + assert_eq!( + queue.enqueue(vec!["/tmp/second.pdf".into(), "/tmp/第三.pdf".into()]), + None, + ); + assert_eq!( + queue.take_pending(), + vec![ + "/tmp/first file.pdf", + "/tmp/second.pdf", + "/tmp/第三.pdf", + ], + ); +} + +#[test] +fn os_open_queue_switches_atomically_to_live_events() { + let mut queue = crate::os_open::OpenedPathQueue::default(); + queue.enqueue(vec!["/tmp/cold.pdf".into()]); + + assert_eq!(queue.take_pending(), vec!["/tmp/cold.pdf"]); + assert_eq!(queue.enqueue(vec!["/tmp/live.pdf".into()]), Some(vec!["/tmp/live.pdf"])); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4a3fda2..f98ff0f 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -40,18 +40,20 @@ export function AppShell() { if (!isTauriRuntime()) return; let cancelled = false; let unlisten: (() => void) | undefined; + let intakeQueue = Promise.resolve(); - const apply = async (paths: string[]) => { - if (!paths.length || cancelled) return; - // Neutral workspace intake only — do not start a tool or change route. - const result = await useWorkspace.getState().addPaths(paths); - if (!cancelled) reportIntake(toast, result); + const apply = (paths: string[]) => { + intakeQueue = intakeQueue.then(async () => { + if (!paths.length || cancelled) return; + const result = await useWorkspace.getState().addPaths(paths, { dedupe: true }); + if (!cancelled) reportIntake(toast, result); + }); }; void (async () => { try { const stop = await onOpenedPaths((paths) => { - void apply(paths); + apply(paths); }); if (cancelled) { stop(); @@ -59,7 +61,8 @@ export function AppShell() { } unlisten = stop; const pending = await takeOpenedPaths(); - await apply(pending); + apply(pending); + await intakeQueue; } catch { // Browser / missing IPC — in-app picker still works. } diff --git a/src/state/workspaceStore.test.ts b/src/state/workspaceStore.test.ts index 0d34dcd..5f4d3f1 100644 --- a/src/state/workspaceStore.test.ts +++ b/src/state/workspaceStore.test.ts @@ -121,6 +121,29 @@ describe("workspace image imports", () => { }); describe("OS-open workspace intake", () => { + it("deduplicates repeated OS-open paths while preserving first-seen order", async () => { + const result = await useWorkspace + .getState() + .addPaths( + [ + "/tmp/second file.pdf", + "/tmp/first.pdf", + "/tmp/second file.pdf", + "/tmp/第三.pdf", + "/tmp/first.pdf", + ], + { dedupe: true }, + ); + + expect(result.added).toBe(3); + expect(useWorkspace.getState().files.map((file) => file.path)).toEqual([ + "/tmp/second file.pdf", + "/tmp/first.pdf", + "/tmp/第三.pdf", + ]); + expect(commands.getFileInfo).toHaveBeenCalledTimes(3); + }); + it("rejects OS-open of report.pdf.exe and .zip without adding them", async () => { const result = await useWorkspace .getState() diff --git a/src/state/workspaceStore.ts b/src/state/workspaceStore.ts index 05e8674..9ce453a 100644 --- a/src/state/workspaceStore.ts +++ b/src/state/workspaceStore.ts @@ -35,6 +35,10 @@ export interface AddResult { errors: string[]; } +export interface AddPathsOptions { + dedupe?: boolean; +} + function baseName(p: string): string { return p.split(/[\\/]/).pop() || p; } @@ -45,7 +49,7 @@ interface WorkspaceState { activeIndex: number; loading: boolean; - addPaths: (paths: string[]) => Promise; + addPaths: (paths: string[], options?: AddPathsOptions) => Promise; removeAt: (index: number) => void; clear: () => void; reorder: (from: number, to: number) => void; @@ -57,10 +61,10 @@ export const useWorkspace = create((set, get) => ({ activeIndex: 0, loading: false, - addPaths: async (paths) => { - const supported = paths.filter((p) => SUPPORTED_RE.test(p)); - // True if some dropped/picked items were not a supported type at all. - const notPdf = supported.length < paths.length; + addPaths: async (paths, options) => { + const inputPaths = options?.dedupe ? [...new Set(paths)] : paths; + const supported = inputPaths.filter((p) => SUPPORTED_RE.test(p)); + const notPdf = supported.length < inputPaths.length; if (supported.length === 0) return { added: 0, invalid: [], notPdf, errors: [] }; activeAddOperations += 1; @@ -77,6 +81,10 @@ export const useWorkspace = create((set, get) => ({ for (const p of supported) { try { if (isImagePath(p)) { + if (options?.dedupe && existing.has(p)) { + infos.push(null); + continue; + } let pdfPath = convertedImages.get(p); if (!pdfPath) { pdfPath = await imageToPdfSerial(p); @@ -84,6 +92,10 @@ export const useWorkspace = create((set, get) => ({ } infos.push(await getFileInfo(pdfPath)); } else if (isOfficePath(p)) { + if (options?.dedupe && existing.has(p)) { + infos.push(null); + continue; + } infos.push(await getFileInfo(await officeToPdf(p))); } else if (existing.has(p)) { infos.push(null); // already loaded