Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions src-tauri/src/os_open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ pub struct OpenedPathQueue {
frontend_ready: bool,
}

impl OpenedPathQueue {
fn enqueue(&mut self, paths: Vec<String>) -> Option<Vec<String>> {
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<String> {
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<Item = impl AsRef<str>>) -> Vec<PathBuf> {
let mut iter = args.into_iter();
Expand Down Expand Up @@ -69,11 +90,9 @@ pub fn enqueue_opened_paths(app: &AppHandle, paths: Vec<PathBuf>) {
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);
}
}

Expand All @@ -93,8 +112,17 @@ pub fn take_opened_paths(queue: State<'_, Mutex<OpenedPathQueue>>) -> Vec<String
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.frontend_ready = true;
std::mem::take(&mut guard.pending)
guard.take_pending()
}

fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
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> {
Expand Down
35 changes: 35 additions & 0 deletions src-tauri/src/os_open_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]));
}
17 changes: 10 additions & 7 deletions src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,29 @@ 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();
return;
}
unlisten = stop;
const pending = await takeOpenedPaths();
await apply(pending);
apply(pending);
await intakeQueue;
} catch {
// Browser / missing IPC — in-app picker still works.
}
Expand Down
23 changes: 23 additions & 0 deletions src/state/workspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
22 changes: 17 additions & 5 deletions src/state/workspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export interface AddResult {
errors: string[];
}

export interface AddPathsOptions {
dedupe?: boolean;
}

function baseName(p: string): string {
return p.split(/[\\/]/).pop() || p;
}
Expand All @@ -45,7 +49,7 @@ interface WorkspaceState {
activeIndex: number;
loading: boolean;

addPaths: (paths: string[]) => Promise<AddResult>;
addPaths: (paths: string[], options?: AddPathsOptions) => Promise<AddResult>;
removeAt: (index: number) => void;
clear: () => void;
reorder: (from: number, to: number) => void;
Expand All @@ -57,10 +61,10 @@ export const useWorkspace = create<WorkspaceState>((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;
Expand All @@ -77,13 +81,21 @@ export const useWorkspace = create<WorkspaceState>((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);
convertedImages.set(p, pdfPath);
}
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
Expand Down