fix(workbench): Route files opened from inside the app to their project window (#159)

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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 00:58:25 +08:00
1 джерело 0c9c51b0f5
коміт e43b0b81c7
5 files changed
+102 -49

No files matched your search

+10 -5
Переглянути файл
@@ -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<PathBuf>) -> IdeResult<OpenOutcome> {
windows::open_files_window(&app, window.label(), files)
pub async fn window_open_files(
app: AppHandle,
window: WebviewWindow,
files: Vec<PathBuf>,
) -> IdeResult<Vec<ide_core::IpcPath>> {
Ok(windows::open_files_window(&app, window.label(), files))
}
/// [`window_open_project`] for folder `path` on the host of SSH configuration
+67 -28
Переглянути файл
@@ -207,14 +207,6 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option<WebviewWindow
})
}
/// The window editing loose files (LightEdit), if any: more files open there.
pub fn find_light_window(app: &AppHandle) -> Option<WebviewWindow> {
let state = app.try_state::<AppState>()?;
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<PathBuf>) {
app.state::<WindowsState>().add_files(window.label(), files);
@@ -222,10 +214,6 @@ fn send_files(app: &AppHandle, window: &WebviewWindow, files: Vec<PathBuf>) {
focus(window);
}
fn has_project(app: &AppHandle, label: &str) -> bool {
app.try_state::<AppState>().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<WebviewWindow> {
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<PathBuf>) -> IdeResult<OpenOutcome> {
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<PathBuf>) -> Vec<ide_core::IpcPath> {
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<PathBuf>, files: Vec<PathBuf>) -> 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<PathBuf>,
windows: &[(String, Holds)],
) -> (Vec<OpenStep>, Vec<PathBuf>) {
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<PathBuf>) -> 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<PathBuf>) {
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)];
+5 -4
Переглянути файл
@@ -587,11 +587,12 @@ export const commands = {
*/
windowOpenProject: (path: string, newWindow: boolean, files: string[]) => typedError<OpenOutcome, IdeError>(__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<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_files", { files })),
windowOpenFiles: (files: string[]) => typedError<IpcPath[], IdeError>(__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.
+14 -6
Переглянути файл
@@ -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");
+6 -6
Переглянути файл
@@ -80,18 +80,18 @@ async function openFilesHere(files: readonly string[]): Promise<void> {
}
/**
* 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<void> {
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);
}