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>
This commit is contained in:
arookieofc
2026-11-02 10:03:59 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent fb55847afa
commit c5ff0de1ee
4 changed files with 107 additions and 16 deletions
+8 -6
View File
@@ -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}`;
}
+74
View File
@@ -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<TerminalLink, { kind: "file" }> => {
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" });
});
});
+17 -10
View File
@@ -137,25 +137,32 @@ export const LINK_HANDLER: ILinkHandler = {
export async function openFileLink(link: Extract<TerminalLink, { kind: "file" }>, 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 });
}
+8
View File
@@ -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", () => {