From e43b0b81c7a97f6e7301fb9b96a51a0bd78f9769 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:58:25 +0800 Subject: [PATCH] fix(workbench): Route files opened from inside the app to their project window (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files opened from inside the app (File › Open File…, a drop on the Welcome screen, Open File in a single-file window) were always kept in the requesting window, so a file belonging to a project open in another window went to the single-file window instead. These paths now follow the same rule as Finder, Dock and second-instance opens: window_open_files normalises the paths and routes them through plan_open via plan_open_files, which sends files inside an open local project to that project's window. It returns only the files the requesting window should open itself. The TypeScript side no longer has any routing logic. Files dropped onto a project window still open in that window. Refs #128 --- src-tauri/src/commands/window.rs | 15 ++-- src-tauri/src/windows.rs | 95 +++++++++++++++------- src/bindings.ts | 9 +- src/features/app/__tests__/windows.test.ts | 20 +++-- src/features/app/windows.ts | 12 +-- 5 files changed, 102 insertions(+), 49 deletions(-) diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index ca91bc11d..c54585183 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -23,13 +23,18 @@ pub async fn window_open_project( windows::open_project_window(&app, &path, new_window, files) } -/// Loose `files` opened from a window without a local project: handed to the LightEdit -/// window when another one exists (`focused`), opened in a new one from a remote project -/// window (`opened`), else `current` (the caller opens them in LightEdit itself). +/// Loose `files` opened from a window without a local project: a file inside a local project +/// open in some window goes to that window, the others to the LightEdit window (this one, +/// another, or a new one from a remote project window). Returns the files this window opens +/// itself, in LightEdit. #[tauri::command] #[specta::specta] -pub async fn window_open_files(app: AppHandle, window: WebviewWindow, files: Vec) -> IdeResult { - windows::open_files_window(&app, window.label(), files) +pub async fn window_open_files( + app: AppHandle, + window: WebviewWindow, + files: Vec, +) -> IdeResult> { + Ok(windows::open_files_window(&app, window.label(), files)) } /// [`window_open_project`] for folder `path` on the host of SSH configuration diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index a56188415..b66b72b8a 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -207,14 +207,6 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option Option { - let state = app.try_state::()?; - let windows = app.webview_windows(); - let labels = sorted_labels(windows.keys().map(String::as_str).filter(|l| !is_settings_window(l))); - labels.into_iter().find(|l| state.project(l).is_ok_and(|p| p.light)).and_then(|l| windows.get(&l).cloned()) -} - /// Hands `files` to the already loaded `window` (its frontend takes them on [`LAUNCH_EVENT`]) and focuses it. fn send_files(app: &AppHandle, window: &WebviewWindow, files: Vec) { app.state::().add_files(window.label(), files); @@ -222,10 +214,6 @@ fn send_files(app: &AppHandle, window: &WebviewWindow, files: Vec) { focus(window); } -fn has_project(app: &AppHandle, label: &str) -> bool { - app.try_state::().is_some_and(|s| s.project(label).is_ok() || s.remote_dev.get(label).is_some()) -} - /// The focused window, else the last focused, else any. pub fn target_window(app: &AppHandle) -> Option { let windows = app.webview_windows(); @@ -398,19 +386,13 @@ pub fn open_project_window( Ok(OpenOutcome::Opened { label: window.label().to_owned() }) } -/// Loose local `files` asked for by a window without a local project: they join -/// the LightEdit window when another one has it open; a window busy with a (remote) -/// project gets a new LightEdit window; else the caller opens them itself (`Current`). -pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec) -> IdeResult { - if let Some(w) = find_light_window(app).filter(|w| w.label() != caller) { - send_files(app, &w, files); - return Ok(OpenOutcome::Focused { label: w.label().to_owned() }); - } - if !has_project(app, caller) { - return Ok(OpenOutcome::Current); - } - let window = create_window_with(app, None, files)?; - Ok(OpenOutcome::Opened { label: window.label().to_owned() }) +/// Loose local `files` asked for by window `caller` (File | Open File, a drop on the Welcome +/// screen), routed like [`open_paths`]; returns the files `caller` opens itself. +pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec) -> Vec { + let files = files.into_iter().map(|f| dunce::canonicalize(&f).unwrap_or(f)).collect(); + let (steps, here) = plan_open_files(caller, files, &window_holds(app, Some(caller))); + run_open(app, steps); + here.into_iter().map(Into::into).collect() } /// [`open_project_window`] for folder `path` on the host of SSH configuration @@ -554,6 +536,27 @@ fn path_launch_of(project: Option, files: Vec) -> Launch { } } +/// [`plan_open`] for loose `files` asked for by window `caller` (listed first in `windows`): +/// no other free window takes them. Returns the steps for other windows and the files +/// `caller` opens itself (in its LightEdit session, or turning into one). +pub fn plan_open_files( + caller: &str, + files: Vec, + windows: &[(String, Holds)], +) -> (Vec, Vec) { + let windows: Vec<_> = windows.iter().filter(|(l, h)| l == caller || *h != Holds::Nothing).cloned().collect(); + let mut here = Vec::new(); + let mut steps = Vec::new(); + for step in plan_open(files.into_iter().map(|f| (f, false)).collect(), &windows) { + match step { + OpenStep::Files(label, files) if label == caller => here.extend(files), + OpenStep::Launch(label, launch) if label == caller => here.extend(launch.files.into_iter().map(|f| f.0)), + step => steps.push(step), + } + } + (steps, here) +} + /// Canonical existing `paths` with whether each is a folder; the others are logged and dropped. fn resolve(paths: Vec) -> Vec<(PathBuf, bool)> { paths @@ -580,13 +583,17 @@ pub fn open_path(app: &AppHandle, path: &Path) { /// see [`plan_open`]; the focused window is the preferred free one. pub fn open_paths(app: &AppHandle, paths: Vec) { let preferred = target_window(app).map(|w| w.label().to_owned()); + run_open(app, plan_open(resolve(paths), &window_holds(app, preferred.as_deref()))); +} + +/// What each window holds, `preferred` first. +fn window_holds(app: &AppHandle, preferred: Option<&str>) -> Vec<(String, Holds)> { let mut labels = project_window_labels(app); - if let Some(i) = preferred.and_then(|p| labels.iter().position(|l| *l == p)) { + if let Some(i) = preferred.and_then(|p| labels.iter().position(|l| l == p)) { let first = labels.remove(i); labels.insert(0, first); } - let windows: Vec<(String, Holds)> = labels.into_iter().map(|l| (l.clone(), holds(app, &l))).collect(); - run_open(app, plan_open(resolve(paths), &windows)); + labels.into_iter().map(|l| (l.clone(), holds(app, &l))).collect() } /// [`open_paths`] while the app is starting, before the `main` window exists: it takes them as its launch. @@ -910,6 +917,38 @@ mod tests { ); } + #[test] + fn files_opened_from_a_window_go_to_their_project_window_else_light_edit() { + let windows = [ + w("main", Holds::Nothing), + w("project-1", Holds::Project(p("/proj"))), + w("project-2", Holds::Nothing), + w("project-3", Holds::Other), + ]; + let files = vec![p("/proj/src/a.rs"), p("/x/b.txt")]; + // The Welcome window turns into LightEdit itself; another free window doesn't take the files. + let (steps, here) = plan_open_files("main", files.clone(), &windows); + assert_eq!(steps, [OpenStep::Files("project-1".into(), vec![p("/proj/src/a.rs")])]); + assert_eq!(here, [p("/x/b.txt")]); + // A LightEdit window keeps loose files, even with another one open. + let light = + [w("main", Holds::LightEdit), w("project-1", Holds::Project(p("/proj"))), w("project-2", Holds::LightEdit)]; + let (steps, here) = plan_open_files("main", files.clone(), &light); + assert_eq!(steps, [OpenStep::Files("project-1".into(), vec![p("/proj/src/a.rs")])]); + assert_eq!(here, [p("/x/b.txt")]); + // An existing LightEdit window takes them from a Welcome window. + let others = [w("main", Holds::Nothing), w("project-2", Holds::LightEdit)]; + let (steps, here) = plan_open_files("main", vec![p("/x/b.txt")], &others); + assert_eq!((steps, here), (vec![OpenStep::Files("project-2".into(), vec![p("/x/b.txt")])], vec![])); + // A remote project window gets a new LightEdit window, never a free one or its own. + let (steps, here) = plan_open_files("project-3", files, &windows); + assert_eq!( + steps, + [OpenStep::Files("project-1".into(), vec![p("/proj/src/a.rs")]), OpenStep::New(None, vec![p("/x/b.txt")])] + ); + assert!(here.is_empty()); + } + #[test] fn files_opened_while_running_take_a_free_window_else_a_new_one() { let free = [w("main", Holds::Project(p("/proj"))), w("project-1", Holds::Nothing)]; diff --git a/src/bindings.ts b/src/bindings.ts index bcacf5b7d..114ff9f32 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -587,11 +587,12 @@ export const commands = { */ windowOpenProject: (path: string, newWindow: boolean, files: string[]) => typedError(__TAURI_INVOKE("window_open_project", { path, newWindow, files })), /** - * Loose `files` opened from a window without a local project: handed to the LightEdit - * window when another one exists (`focused`), opened in a new one from a remote project - * window (`opened`), else `current` (the caller opens them in LightEdit itself). + * Loose `files` opened from a window without a local project: a file inside a local project + * open in some window goes to that window, the others to the LightEdit window (this one, + * another, or a new one from a remote project window). Returns the files this window opens + * itself, in LightEdit. */ - windowOpenFiles: (files: string[]) => typedError(__TAURI_INVOKE("window_open_files", { files })), + windowOpenFiles: (files: string[]) => typedError(__TAURI_INVOKE("window_open_files", { files })), /** * [`window_open_project`] for folder `path` on the host of SSH configuration * `ssh_config` (Remote Development); a new window connects itself. diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index 94de77495..59dabc3fe 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -90,7 +90,7 @@ describe("openProjectInWindow", () => { describe("loose files (LightEdit)", () => { it("turns a window without a project into LightEdit when no LightEdit window exists", async () => { - windowOpenFiles.mockResolvedValue(ok({ kind: "current" })); + windowOpenFiles.mockResolvedValue(ok(["/x/a.txt", "/y/b.md"])); await openFiles(["/x/a.txt", "/y/b.md"]); expect(windowOpenFiles).toHaveBeenCalledWith(["/x/a.txt", "/y/b.md"]); expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt"); @@ -98,18 +98,26 @@ describe("loose files (LightEdit)", () => { expect(opened).toEqual(["/x/a.txt", "/y/b.md (focus)"]); }); - it("leaves files to the existing LightEdit window", async () => { - windowOpenFiles.mockResolvedValue(ok({ kind: "focused", label: "project-2" })); + it("leaves files to the existing LightEdit window or their project's window", async () => { + windowOpenFiles.mockResolvedValue(ok([])); await openFiles(["/x/a.txt"]); expect(lightEditOpen).not.toHaveBeenCalled(); expect(opened).toEqual([]); expect(useProject.getState().lightEdit).toBe(false); }); + it("opens only the files the backend leaves to this window", async () => { + windowOpenFiles.mockResolvedValue(ok(["/x/b.txt"])); + await openFiles(["/p/a.rs", "/x/b.txt"]); + expect(lightEditOpen).toHaveBeenCalledWith("/x/b.txt"); + expect(opened).toEqual(["/x/b.txt (focus)"]); + }); + it("adds tabs to this LightEdit window", async () => { useProject.setState({ lightEdit: true }); + windowOpenFiles.mockResolvedValue(ok(["/x/c.txt"])); await openFiles(["/x/c.txt"]); - expect(windowOpenFiles).not.toHaveBeenCalled(); + expect(windowOpenFiles).toHaveBeenCalledWith(["/x/c.txt"]); expect(lightEditOpen).not.toHaveBeenCalled(); expect(opened).toEqual(["/x/c.txt (focus)"]); }); @@ -123,7 +131,7 @@ describe("loose files (LightEdit)", () => { }); it("routes dropped files to LightEdit and the dropped folder to a project", async () => { - windowOpenFiles.mockResolvedValue(ok({ kind: "current" })); + windowOpenFiles.mockResolvedValue(ok(["/x/a.txt"])); windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" })); useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never); await openPaths(["/x/a.txt", "/work/app"], async (p) => p === "/work/app"); @@ -141,7 +149,7 @@ describe("loose files (LightEdit)", () => { it("classifies paths through the file system by default", async () => { fsReadDir.mockResolvedValueOnce({ status: "error", error: { kind: "notFound", message: "not a dir" } } as never); - windowOpenFiles.mockResolvedValue(ok({ kind: "current" })); + windowOpenFiles.mockResolvedValue(ok(["/x/a.txt"])); await openPaths(["/x/a.txt"]); expect(fsReadDir).toHaveBeenCalledWith("/x/a.txt"); expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt"); diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 09c3c66f2..38c03b080 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -80,18 +80,18 @@ async function openFilesHere(files: readonly string[]): Promise { } /** - * Opens loose `files`: in this window's local project or LightEdit session, else - * in the LightEdit window if there is one, else this window turns into LightEdit - * (a remote project window gets a new one). + * Opens loose `files`: in this window's local project; else a file inside a project open in + * another window opens there and the rest in LightEdit (this window's session, the LightEdit + * window, or this window turns into one; a remote project window gets a new one). */ export async function openFiles(files: readonly string[]): Promise { if (!files.length) return; const s = useProject.getState(); - if (s.lightEdit || (s.project && !isRemoteWindow())) return openInEditor(files); + if (s.project && !isRemoteWindow()) return openInEditor(files); if (desktopOnly()) return; try { - const outcome = await call(commands.windowOpenFiles([...files])); - if (outcome.kind === "current") await openFilesHere(files); + const here = await call(commands.windowOpenFiles([...files])); + if (here.length) await openFilesHere(here); } catch (e) { openFailed(files[0]!, e); }