Skip to content

Commit 1f4a3f6

Browse files
authored
Fix opening urls wrapped across lines in the terminal (#1913)
1 parent 26cc1ff commit 1f4a3f6

3 files changed

Lines changed: 233 additions & 10 deletions

File tree

apps/web/src/components/ThreadTerminalDrawer.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
2121
import { type TerminalContextSelection } from "~/lib/terminalContext";
2222
import { openInPreferredEditor } from "../editorPreferences";
2323
import {
24+
collectWrappedTerminalLinkLine,
2425
extractTerminalLinks,
2526
isTerminalLinkActivation,
2627
resolvePathLinkTarget,
28+
resolveWrappedTerminalLinkRange,
29+
wrappedTerminalLinkRangeIntersectsBufferLine,
2730
} from "../terminal-links";
2831
import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
2932
import {
@@ -419,26 +422,31 @@ export function TerminalViewport({
419422
return;
420423
}
421424

422-
const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1);
423-
if (!line) {
425+
const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) =>
426+
activeTerminal.buffer.active.getLine(bufferLineIndex),
427+
);
428+
if (!wrappedLine) {
424429
callback(undefined);
425430
return;
426431
}
427432

428-
const lineText = line.translateToString(true);
429-
const matches = extractTerminalLinks(lineText);
430-
if (matches.length === 0) {
433+
const links = extractTerminalLinks(wrappedLine.text)
434+
.map((match) => ({
435+
match,
436+
range: resolveWrappedTerminalLinkRange(wrappedLine, match),
437+
}))
438+
.filter(({ range }) =>
439+
wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber),
440+
);
441+
if (links.length === 0) {
431442
callback(undefined);
432443
return;
433444
}
434445

435446
callback(
436-
matches.map((match) => ({
447+
links.map(({ match, range }) => ({
437448
text: match.text,
438-
range: {
439-
start: { x: match.start + 1, y: bufferLineNumber },
440-
end: { x: match.end, y: bufferLineNumber },
441-
},
449+
range,
442450
activate: (event: MouseEvent) => {
443451
if (!isTerminalLinkActivation(event)) return;
444452

apps/web/src/terminal-links.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
4+
collectWrappedTerminalLinkLine,
45
extractTerminalLinks,
56
isTerminalLinkActivation,
67
resolvePathLinkTarget,
8+
resolveWrappedTerminalLinkRange,
9+
wrappedTerminalLinkRangeIntersectsBufferLine,
10+
type TerminalBufferLineLike,
711
} from "./terminal-links";
812

13+
function createBufferLine(text: string, isWrapped = false): TerminalBufferLineLike {
14+
return {
15+
isWrapped,
16+
translateToString: (trimRight = false) => (trimRight ? text.replace(/\s+$/u, "") : text),
17+
};
18+
}
19+
920
describe("extractTerminalLinks", () => {
1021
it("finds http urls and path tokens", () => {
1122
const line =
@@ -71,6 +82,99 @@ describe("extractTerminalLinks", () => {
7182
});
7283
});
7384

85+
describe("collectWrappedTerminalLinkLine", () => {
86+
it("reconstructs a wrapped line from any physical row", () => {
87+
const firstSegment = "see https://example.com/a";
88+
const secondSegment = "/bc?x=1";
89+
const lines = [
90+
createBufferLine("prompt> "),
91+
createBufferLine(firstSegment),
92+
createBufferLine(secondSegment, true),
93+
createBufferLine("done"),
94+
];
95+
96+
const fromFirstRow = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
97+
const fromWrappedRow = collectWrappedTerminalLinkLine(3, (index) => lines[index]);
98+
99+
expect(fromFirstRow).toEqual({
100+
text: `${firstSegment}${secondSegment}`,
101+
segments: [
102+
{
103+
bufferLineNumber: 2,
104+
text: firstSegment,
105+
startIndex: 0,
106+
endIndex: firstSegment.length,
107+
},
108+
{
109+
bufferLineNumber: 3,
110+
text: secondSegment,
111+
startIndex: firstSegment.length,
112+
endIndex: firstSegment.length + secondSegment.length,
113+
},
114+
],
115+
});
116+
expect(fromWrappedRow).toEqual(fromFirstRow);
117+
});
118+
119+
it("preserves trailing spaces on continued segments for downstream offsets", () => {
120+
const firstSegment = "prefix ";
121+
const secondSegment = "https://example.com/path";
122+
const lines = [createBufferLine(firstSegment), createBufferLine(secondSegment, true)];
123+
124+
const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
125+
126+
expect(wrappedLine?.text).toBe(`${firstSegment}${secondSegment}`);
127+
expect(extractTerminalLinks(wrappedLine?.text ?? "")).toEqual([
128+
{
129+
kind: "url",
130+
text: secondSegment,
131+
start: firstSegment.length,
132+
end: firstSegment.length + secondSegment.length,
133+
},
134+
]);
135+
});
136+
});
137+
138+
describe("resolveWrappedTerminalLinkRange", () => {
139+
it("maps wrapped URL matches back to the correct buffer rows", () => {
140+
const prefix = "see ";
141+
const firstSegment = `${prefix}https://example.com/a`;
142+
const secondSegment = "/bc?x=1";
143+
const lines = [
144+
createBufferLine("prompt> "),
145+
createBufferLine(firstSegment),
146+
createBufferLine(secondSegment, true),
147+
];
148+
const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
149+
150+
expect(wrappedLine).not.toBeNull();
151+
if (!wrappedLine) {
152+
throw new Error("Expected wrapped terminal line to be present.");
153+
}
154+
155+
const [match] = extractTerminalLinks(wrappedLine.text);
156+
expect(match).toEqual({
157+
kind: "url",
158+
text: "https://example.com/a/bc?x=1",
159+
start: prefix.length,
160+
end: firstSegment.length + secondSegment.length,
161+
});
162+
if (!match) {
163+
throw new Error("Expected wrapped URL match to be present.");
164+
}
165+
166+
const range = resolveWrappedTerminalLinkRange(wrappedLine, match);
167+
168+
expect(range).toEqual({
169+
start: { x: prefix.length + 1, y: 2 },
170+
end: { x: secondSegment.length, y: 3 },
171+
});
172+
expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true);
173+
expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true);
174+
expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false);
175+
});
176+
});
177+
74178
describe("resolvePathLinkTarget", () => {
75179
it("resolves relative paths against cwd", () => {
76180
expect(

apps/web/src/terminal-links.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,33 @@ export interface TerminalLinkMatch {
99
end: number;
1010
}
1111

12+
export interface TerminalLinkBufferPosition {
13+
x: number;
14+
y: number;
15+
}
16+
17+
export interface TerminalLinkBufferRange {
18+
start: TerminalLinkBufferPosition;
19+
end: TerminalLinkBufferPosition;
20+
}
21+
22+
export interface TerminalBufferLineLike {
23+
readonly isWrapped?: boolean;
24+
translateToString(trimRight?: boolean): string;
25+
}
26+
27+
export interface WrappedTerminalLinkLineSegment {
28+
bufferLineNumber: number;
29+
text: string;
30+
startIndex: number;
31+
endIndex: number;
32+
}
33+
34+
export interface WrappedTerminalLinkLine {
35+
text: string;
36+
segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;
37+
}
38+
1239
const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g;
1340
const FILE_PATH_PATTERN =
1441
/(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
@@ -145,6 +172,90 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
145172
return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start);
146173
}
147174

175+
export function collectWrappedTerminalLinkLine(
176+
bufferLineNumber: number,
177+
getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined,
178+
): WrappedTerminalLinkLine | null {
179+
const anchorLine = getLine(bufferLineNumber - 1);
180+
if (!anchorLine) return null;
181+
182+
let startBufferLineNumber = bufferLineNumber;
183+
let startLine = anchorLine;
184+
185+
while (startBufferLineNumber > 1 && startLine.isWrapped) {
186+
const previousLine = getLine(startBufferLineNumber - 2);
187+
if (!previousLine) return null;
188+
startBufferLineNumber -= 1;
189+
startLine = previousLine;
190+
}
191+
192+
const segments: WrappedTerminalLinkLineSegment[] = [];
193+
let nextStartIndex = 0;
194+
let currentBufferLineNumber = startBufferLineNumber;
195+
196+
while (true) {
197+
const currentLine = getLine(currentBufferLineNumber - 1);
198+
if (!currentLine) break;
199+
200+
const nextLine = getLine(currentBufferLineNumber);
201+
const hasWrappedContinuation = nextLine?.isWrapped === true;
202+
const text = currentLine.translateToString(!hasWrappedContinuation);
203+
204+
segments.push({
205+
bufferLineNumber: currentBufferLineNumber,
206+
text,
207+
startIndex: nextStartIndex,
208+
endIndex: nextStartIndex + text.length,
209+
});
210+
nextStartIndex += text.length;
211+
212+
if (!hasWrappedContinuation) break;
213+
currentBufferLineNumber += 1;
214+
}
215+
216+
return {
217+
text: segments.map((segment) => segment.text).join(""),
218+
segments,
219+
};
220+
}
221+
222+
function resolveCharacterPosition(
223+
segments: ReadonlyArray<WrappedTerminalLinkLineSegment>,
224+
characterIndex: number,
225+
): TerminalLinkBufferPosition {
226+
for (const segment of segments) {
227+
if (characterIndex < segment.endIndex) {
228+
return {
229+
x: characterIndex - segment.startIndex + 1,
230+
y: segment.bufferLineNumber,
231+
};
232+
}
233+
}
234+
235+
const lastSegment = segments[segments.length - 1];
236+
return {
237+
x: Math.max(lastSegment?.text.length ?? 0, 1),
238+
y: lastSegment?.bufferLineNumber ?? 1,
239+
};
240+
}
241+
242+
export function resolveWrappedTerminalLinkRange(
243+
wrappedLine: WrappedTerminalLinkLine,
244+
match: Pick<TerminalLinkMatch, "start" | "end">,
245+
): TerminalLinkBufferRange {
246+
return {
247+
start: resolveCharacterPosition(wrappedLine.segments, match.start),
248+
end: resolveCharacterPosition(wrappedLine.segments, match.end - 1),
249+
};
250+
}
251+
252+
export function wrappedTerminalLinkRangeIntersectsBufferLine(
253+
range: TerminalLinkBufferRange,
254+
bufferLineNumber: number,
255+
): boolean {
256+
return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y;
257+
}
258+
148259
export function isTerminalLinkActivation(
149260
event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
150261
platform = typeof navigator === "undefined" ? "" : navigator.platform,

0 commit comments

Comments
 (0)