Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/web/src/terminal-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,62 @@ function createBufferLine(text: string, isWrapped = false): TerminalBufferLineLi
}

describe("extractTerminalLinks", () => {
it.each([
String.raw`src\index.ts:12`,
String.raw`.\src\index.ts:12:7`,
String.raw`..\src\index.ts:12`,
String.raw`C:\Users\Jane\project\index.ts:12`,
String.raw`\\server\share\index.ts:12`,
])("finds Windows path %s", (text) => {
const line = `error at ${text}: failed`;
expect(extractTerminalLinks(line)).toEqual([
{ kind: "path", text, start: 9, end: 9 + text.length },
]);
});

it.each(['"', "'", "`"])("finds paths containing spaces enclosed in %s", (quote) => {
const text = String.raw`C:\Users\Jane Doe\project\index.ts:12:7`;
expect(extractTerminalLinks(`see ${quote}${text}${quote}.`)).toEqual([
{ kind: "path", text, start: 5, end: 5 + text.length },
]);
});

it.each([
String.raw`src\my folder\index.ts`,
"./my folder/index.ts",
"/home/Jane Doe/index.ts",
"/home/Jane Doe/file)",
String.raw`\\server\my share\index.ts`,
])("keeps positions outside quotes on %s", (path) => {
expect(extractTerminalLinks(`"${path}":12:7: error`)).toEqual([
{ kind: "path", text: `${path}:12:7`, start: 1, end: path.length + 7 },
]);
});

it("does not treat quoted prose as a path", () => {
expect(extractTerminalLinks('error: "something went wrong"')).toEqual([]);
expect(extractTerminalLinks('error: "something went wrong."')).toEqual([]);
});

it.each([
['"my file.ts":12:7', "my file.ts:12:7", 17],
["'my file.ts:12:7'", "my file.ts:12:7", 16],
["`my file.ts`", "my file.ts", 11],
])("finds a quoted basename in %s", (line, text, end) => {
expect(extractTerminalLinks(line)).toEqual([{ kind: "path", text, start: 1, end }]);
});

it("keeps quoted paths and URLs separate without duplicate partial paths", () => {
const path = String.raw`C:\Jane Doe\index.ts:12`;
const url = "https://example.com/docs";
const line = `"${path}" ${url} src/index.ts`;
expect(extractTerminalLinks(line)).toEqual([
{ kind: "path", text: path, start: 1, end: 1 + path.length },
{ kind: "url", text: url, start: path.length + 3, end: path.length + 3 + url.length },
{ kind: "path", text: "src/index.ts", start: line.length - 12, end: line.length },
]);
});

it("finds http urls and path tokens", () => {
const line =
"failed at https://example.com/docs and src/components/ThreadTerminalDrawer.tsx:42";
Expand Down Expand Up @@ -171,6 +227,13 @@ describe("collectWrappedTerminalLinkLine", () => {
});

describe("resolvePathLinkTarget", () => {
it("resolves a quoted terminal link against a Windows environment", () => {
const [link] = extractTerminalLinks(String.raw`error at "src\my folder\index.ts":12:7`);
expect(resolvePathLinkTarget(link?.text ?? "", String.raw`C:\Users\Jane Doe\project`)).toBe(
String.raw`C:\Users\Jane Doe\project\src\my folder\index.ts:12:7`,
);
});

it("resolves relative paths against cwd", () => {
expect(
resolvePathLinkTarget(
Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/terminal-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export interface WrappedTerminalLinkLine {

const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;
const FILE_PATH_PATTERN =
/(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
/(?:~[\\/]|\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:[\\/][A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
const QUOTED_FILE_PATH_PATTERN =
/(["'`])(?<path>(?:~[\\/]|\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\\\|[A-Za-z0-9._ -]+[\\/])[^\r\n]*?|[A-Za-z0-9._ -]+\.[A-Za-z0-9_-]+(?::\d+){0,2})\1(?<position>(?::\d+){0,2})/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
// Paths also drop a trailing colon: compilers end `file:line:col:` with one.
const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
Expand Down Expand Up @@ -69,19 +71,22 @@ function collectMatches(
pattern.lastIndex = 0;

for (const rawMatch of line.matchAll(pattern)) {
const raw = rawMatch[0];
const start = rawMatch.index ?? -1;
const quotedPath = rawMatch.groups?.path;
const raw =
quotedPath === undefined ? rawMatch[0] : quotedPath + (rawMatch.groups?.position ?? "");
const start = rawMatch.index + (quotedPath === undefined ? 0 : 1);
if (start < 0 || raw.length === 0) continue;

const trimmed = trimClosingDelimiters(raw, kind);
const trimmed = quotedPath === undefined ? trimClosingDelimiters(raw, kind) : raw;
if (trimmed.length === 0) continue;
if (kind === "path" && isTerminalUrl(trimmed)) continue;

const candidate: TerminalLinkMatch = {
kind,
text: trimmed,
start,
end: start + trimmed.length,
// A position outside quotes spans the closing quote in the terminal buffer.
end: start + trimmed.length + (rawMatch.groups?.position ? 1 : 0),
};

const collides = [...existing, ...matches].some((other) => overlaps(candidate, other));
Expand All @@ -95,8 +100,12 @@ function collectMatches(

export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
const urlMatches = collectMatches(line, "url", URL_PATTERN, []);
const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches);
return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start);
const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, urlMatches);
const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, [
...urlMatches,
...quotedMatches,
]);
return [...urlMatches, ...quotedMatches, ...pathMatches].toSorted((a, b) => a.start - b.start);
}

export function isTerminalUrl(value: string): boolean {
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,25 @@ describe("shouldBlinkTerminalCursor", () => {
});

describe("terminalLinkAtPositionWithRange", () => {
it("activates a wrapped quoted Windows path from its spaces and position suffix", () => {
const row = (text: string, isWrapContinuation: boolean): GhosttyRow => ({
cells: Array.from(text, (character) => cell(character)),
text,
isWrapContinuation,
wrapsToNext: false,
});
const first = String.raw`"C:\Users\Jane `;
const second = String.raw`Doe\index.ts":12:7`;
const rows = [row(first, false), row(second, true)];
const expected = {
text: String.raw`C:\Users\Jane Doe\index.ts:12:7`,
range: { start: { x: 1, y: 0 }, end: { x: second.length - 1, y: 1 } },
};
expect(terminalLinkAtPositionWithRange(rows, 0, first.length - 1)).toEqual(expected);
expect(terminalLinkAtPositionWithRange(rows, 1, second.length - 1)).toEqual(expected);
expect(terminalLinkAtPositionWithRange(rows, 0, 0)).toBeNull();
});

it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
const cells = [
cell("🙂"),
Expand Down
26 changes: 26 additions & 0 deletions packages/shared/src/fileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ describe("splitFilePathPosition", () => {
});

describe("resolvePathLinkTarget", () => {
it.each([
[
String.raw`src\index.ts:12:7`,
String.raw`C:\Users\Jane Doe\project`,
String.raw`C:\Users\Jane Doe\project\src\index.ts:12:7`,
],
[
"src/index.ts:12:7",
"C:/Users/Jane Doe/project",
String.raw`C:/Users/Jane Doe/project\src\index.ts:12:7`,
],
[
String.raw`.\src\index.ts:12`,
String.raw`\\server\share\project`,
String.raw`\\server\share\project\.\src\index.ts:12`,
],
[
String.raw`~\index.ts:12`,
String.raw`C:\Users\Jane Doe\project`,
String.raw`C:\Users\Jane Doe\index.ts:12`,
],
["src/index.ts:12", "/home/jane/project", "/home/jane/project/src/index.ts:12"],
])("resolves %s using the connected environment cwd %s", (path, cwd, expected) => {
expect(resolvePathLinkTarget(path, cwd)).toBe(expected);
});

it("resolves relative paths against cwd", () => {
expect(
resolvePathLinkTarget(
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/fileLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
const { path } = position;

let resolvedPath = path;
if (path.startsWith("~/")) {
if (path.startsWith("~/") || (isWindowsPathStyle(cwd) && path.startsWith("~\\"))) {
const home = inferHomeFromCwd(cwd);
if (home) {
const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/";
Expand Down
Loading