From c5ff0de1eeebdb7c7ea27852dcf7801c0a02e804 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 10:03:59 +0800 Subject: [PATCH] fix(terminal): open the file a link names, not a same-named one (#711) resolvePath only understood POSIX paths, so on Windows the direct lookup never matched and every click fell back to a project-wide search by bare file name, which opened any same-named file. Resolve Windows drive paths and backslashes, and make the fallback query include the link's directory and check the hit's project-relative path ends with it; a bare-name match is used only for links without a directory. Fixes #705. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/terminal/links.ts | 14 ++--- src/features/terminal/session.test.ts | 74 ++++++++++++++++++++++++++ src/features/terminal/session.ts | 27 ++++++---- src/features/terminal/terminal.test.ts | 8 +++ 4 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 src/features/terminal/session.test.ts diff --git a/src/features/terminal/links.ts b/src/features/terminal/links.ts index ca2a4acd..51c97189 100644 --- a/src/features/terminal/links.ts +++ b/src/features/terminal/links.ts @@ -60,14 +60,14 @@ export function findTerminalLinks(text: string): TerminalLink[] { return links.sort((a, b) => a.start - b.start); } -/** Resolves `path` against `cwd` (POSIX); `~` expands with `home` when known. */ +/** Resolves `path` against `cwd` (POSIX, or Windows drive paths); `~` expands with `home` when known. */ export function resolvePath(path: string, cwd: string, home?: string | null): string { - let p = path; + let p = path.replace(/\\/g, "/"); if (p === "~" || p.startsWith("~/")) { if (!home) return p; - p = home + p.slice(1); - } else if (!p.startsWith("/")) { - p = `${cwd.replace(/\/+$/, "")}/${p}`; + p = home.replace(/\\/g, "/") + p.slice(1); + } else if (!p.startsWith("/") && !/^[A-Za-z]:\//.test(p)) { + p = `${cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${p}`; } const parts: string[] = []; for (const seg of p.split("/")) { @@ -75,5 +75,7 @@ export function resolvePath(path: string, cwd: string, home?: string | null): st if (seg === "..") parts.pop(); else parts.push(seg); } - return "/" + parts.join("/"); + const joined = parts.join("/"); + // A Windows drive prefix is its own root; POSIX results keep the leading slash. + return /^[A-Za-z]:\//.test(joined) ? joined : `/${joined}`; } diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts new file mode 100644 index 00000000..10134ade --- /dev/null +++ b/src/features/terminal/session.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { findTerminalLinks, type TerminalLink } from "./links"; +import { openFileLink } from "./session"; + +const ipc = vi.hoisted(() => ({ + call: vi.fn(async (v: unknown) => v), + commands: { fsReadDir: vi.fn(), findFiles: vi.fn() }, +})); +const workbench = vi.hoisted(() => ({ + openFile: vi.fn(async () => undefined), + notify: vi.fn(), +})); + +vi.mock("@/platform/ipc", () => ({ call: ipc.call, commands: ipc.commands, isTauri: () => true })); +vi.mock("@/workbench/contributions", () => ({ + editorService: () => ({ openFile: workbench.openFile }), + notifications: () => ({ notify: workbench.notify }), + OWNS_FILE_DROP: { "data-owner": true }, +})); + +const fileLink = (text: string): Extract => { + const [l] = findTerminalLinks(text); + if (!l || l.kind !== "file") throw new Error(`not a file link: ${text}`); + return l; +}; + +describe("openFileLink", () => { + beforeEach(() => vi.clearAllMocks()); + + it("opens the resolved path when the directory lists it", async () => { + ipc.commands.fsReadDir.mockResolvedValue([{ name: "main.php", kind: "file" }]); + await openFileLink(fileLink("a/main.php:2"), "C:\\Users\\me\\proj"); + expect(ipc.commands.fsReadDir).toHaveBeenCalledWith("C:/Users/me/proj/a"); + expect(workbench.openFile).toHaveBeenCalledWith("C:/Users/me/proj/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + expect(ipc.commands.findFiles).not.toHaveBeenCalled(); + }); + + it("ranks the link's directory over the bare name in the project-wide fallback (#705)", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockImplementation(async (q: string) => + q === "a/main.php" + ? [ + { path: "/p/b/main.php", relPath: "b/main.php" }, + { path: "/p/a/main.php", relPath: "a/main.php" }, + ] + : [{ path: "/p/c/main.php", relPath: "c/main.php" }], + ); + await openFileLink(fileLink("a/main.php:2"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledWith("a/main.php", 5); + expect(workbench.openFile).toHaveBeenCalledWith("/p/a/main.php", { + selection: { line: 1, col: 0 }, + focus: true, + }); + }); + + it("falls back to the bare name for a basename-only link", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([{ path: "/p/x/main.php", relPath: "x/main.php" }]); + await openFileLink(fileLink("main.php:1"), "/p"); + expect(ipc.commands.findFiles).toHaveBeenCalledTimes(1); + expect(workbench.openFile).toHaveBeenCalledWith("/p/x/main.php", { selection: { line: 0, col: 0 }, focus: true }); + }); + + it("warns when nothing matches", async () => { + ipc.commands.fsReadDir.mockResolvedValue([]); + ipc.commands.findFiles.mockResolvedValue([]); + await openFileLink(fileLink("gone.php:1"), "/p"); + expect(workbench.openFile).not.toHaveBeenCalled(); + expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" }); + }); +}); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 1f620980..da94bad3 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -137,25 +137,32 @@ export const LINK_HANDLER: ILinkHandler = { export async function openFileLink(link: Extract, cwd: string) { const selection = link.line ? { line: link.line - 1, col: Math.max(0, (link.col ?? 1) - 1) } : undefined; const path = resolvePath(link.path, cwd); - const name = link.path.split("/").pop() ?? link.path; + const rel = link.path.replace(/\\/g, "/"); + const name = rel.split("/").pop() ?? rel; try { const slash = path.lastIndexOf("/"); - const entries = await call(commands.fsReadDir(path.slice(0, slash) || "/")); + const entries = await call(commands.fsReadDir(slash > 0 ? path.slice(0, slash) : "/")); if (entries.some((e) => e.name === name && e.kind !== "dir")) { await editorService().openFile(path, { selection, focus: true }); return; } } catch { - /* fall back to a project-wide lookup by file name */ + /* fall back to a project-wide lookup */ } - try { - const [hit] = await call(commands.findFiles(name, 1)); - if (hit && hit.path.endsWith(name)) { - await editorService().openFile(hit.path, { selection, focus: true }); - return; + // The basename alone is ambiguous (`a/x.ts` vs `b/x.ts`): rank the link's + // directory part too and verify the hit actually sits on that path, settling + // for a basename match only when the link names no directory. + for (const q of rel === name ? [name] : [rel, name]) { + try { + const hits = await call(commands.findFiles(q, 5)); + const hit = hits.find((h) => h.relPath === q || h.relPath.endsWith(`/${q}`)); + if (hit) { + await editorService().openFile(hit.path, { selection, focus: true }); + return; + } + } catch { + /* ignore */ } - } catch { - /* ignore */ } notifications().notify({ level: "warning", title: "File not found", body: link.path }); } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 433749ce..548cb5ed 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -96,6 +96,14 @@ describe("terminal links", () => { expect(resolvePath("/abs/c.ts", "/p")).toBe("/abs/c.ts"); expect(resolvePath("~/d.ts", "/p", "/Users/me")).toBe("/Users/me/d.ts"); }); + + it("resolves paths against a Windows cwd (#705)", () => { + expect(resolvePath("a/main.php", "C:\\Users\\me\\proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("a\\main.php", "C:/Users/me/proj")).toBe("C:/Users/me/proj/a/main.php"); + expect(resolvePath("C:\\abs\\d.ts", "C:/p")).toBe("C:/abs/d.ts"); + expect(resolvePath("~/e.ts", "C:/p", "C:\\Users\\me")).toBe("C:/Users/me/e.ts"); + expect(resolvePath("../f.ts", "C:/p/web")).toBe("C:/p/f.ts"); + }); }); describe("terminal keys", () => {