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); }