Skip to content
Open
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
134 changes: 134 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { GhosttyCell, GhosttyRow } from "./core";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
MAX_HYPERLINK_RANGE_CELLS,
MAX_HYPERLINK_RANGE_URI_LENGTH,
advanceTerminalSelectionClickSequence,
applyTerminalCopyEvent,
clearPrimedTerminalCopyInput,
Expand All @@ -21,6 +23,7 @@ import {
shouldBlinkTerminalCursor,
shouldReportTerminalMouse,
shouldShowTerminalLinkHover,
terminalExplicitHyperlinkRange,
terminalGridCellAt,
terminalScrollbarGeometry,
terminalScrollbarOffsetAtPointer,
Expand Down Expand Up @@ -91,6 +94,137 @@ describe("terminalGridCellAt", () => {
});
});

describe("terminalExplicitHyperlinkRange", () => {
const row = (over: Partial<GhosttyRow> = {}): GhosttyRow => ({
cells: [],
text: "",
isWrapContinuation: false,
wrapsToNext: false,
...over,
});

const range = (
cell: { x: number; y: number },
linked: Set<string>,
rowData: GhosttyRow[],
cols: number,
uri = "https://t3.codes",
) =>
terminalExplicitHyperlinkRange(cell, uri, {
cols,
rows: rowData.length,
rowData,
hasSameHyperlink: (x, y) => linked.has(`${x},${y}`),
});

it("expands over adjacent cells carrying the same hyperlink", () => {
expect(range({ x: 2, y: 0 }, new Set(["1,0", "2,0", "3,0"]), [row()], 5)).toEqual({
start: { x: 1, y: 0 },
end: { x: 3, y: 0 },
});
});

it("stops at cells without the hyperlink and at grid edges", () => {
expect(range({ x: 4, y: 0 }, new Set(["4,0", "5,0"]), [row()], 5)).toEqual({
start: { x: 4, y: 0 },
end: { x: 4, y: 0 },
});
expect(range({ x: 1, y: 0 }, new Set(["1,0"]), [row()], 5)).toEqual({
start: { x: 1, y: 0 },
end: { x: 1, y: 0 },
});
});

it("follows wrapped rows in both directions", () => {
const rowData = [
row({ wrapsToNext: true }),
row({ wrapsToNext: true, isWrapContinuation: true }),
row({ isWrapContinuation: true }),
];
expect(range({ x: 3, y: 0 }, new Set(["3,0", "0,1", "1,1"]), rowData, 4)).toEqual({
start: { x: 3, y: 0 },
end: { x: 1, y: 1 },
});
expect(range({ x: 0, y: 2 }, new Set(["3,1", "0,2"]), rowData, 4)).toEqual({
start: { x: 3, y: 1 },
end: { x: 0, y: 2 },
});
});

it("truncates the walk at the scan cap when every probed cell matches", () => {
const cols = 8;
const rowCount = 600;
const rowData = Array.from({ length: rowCount }, (_, index) =>
row({
wrapsToNext: index < rowCount - 1,
isWrapContinuation: index > 0,
}),
);
let probes = 0;
const result = terminalExplicitHyperlinkRange({ x: 0, y: 0 }, "https://t3.codes", {
cols,
rows: rowCount,
rowData,
hasSameHyperlink: () => {
probes += 1;
return true;
},
});
expect(probes).toBe(MAX_HYPERLINK_RANGE_CELLS);
expect(result.start).toEqual({ x: 0, y: 0 });
expect(result.end).toEqual({
x: MAX_HYPERLINK_RANGE_CELLS % cols,
y: Math.floor(MAX_HYPERLINK_RANGE_CELLS / cols),
});
});

it("counts failed probes toward the scan cap", () => {
const cols = 8;
const rowCount = 600;
const rowData = Array.from({ length: rowCount }, (_, index) =>
row({
wrapsToNext: index < rowCount - 1,
isWrapContinuation: index > 0,
}),
);
let probes = 0;
const result = terminalExplicitHyperlinkRange({ x: 3, y: 0 }, "https://t3.codes", {
cols,
rows: rowCount,
rowData,
hasSameHyperlink: (x, y) => {
probes += 1;
return !(x === 2 && y === 0);
},
});
expect(probes).toBe(MAX_HYPERLINK_RANGE_CELLS);
expect(result.start).toEqual({ x: 3, y: 0 });
expect(result.end).toEqual({
x: (3 + MAX_HYPERLINK_RANGE_CELLS - 1) % cols,
y: Math.floor((3 + MAX_HYPERLINK_RANGE_CELLS - 1) / cols),
});
});

it("keeps oversized URIs to a single-cell range without probing neighbors", () => {
let probes = 0;
const result = terminalExplicitHyperlinkRange(
{ x: 1, y: 1 },
"a".repeat(MAX_HYPERLINK_RANGE_URI_LENGTH + 1),
{
cols: 4,
rows: 2,
rowData: [row(), row()],
hasSameHyperlink: () => {
probes += 1;
return true;
},
},
);
expect(result).toEqual({ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } });
expect(probes).toBe(0);
});
});

describe("shouldBlinkTerminalCursor", () => {
const blinking = {
focused: true,
Expand Down
88 changes: 61 additions & 27 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,58 @@ export function terminalGridCellAt(options: {
};
}

export const MAX_HYPERLINK_RANGE_URI_LENGTH = 4096;

export const MAX_HYPERLINK_RANGE_CELLS = 4096;

// The OSC 8 URI is attacker-controlled and unbounded, and every hyperlink
// probe decodes it in full, so the hover range walk is capped on both factors.
// Without the caps a single oversized hyperlink spanning the viewport makes
// each hover refresh allocate/decode viewport × URI-length bytes and freezes
// the renderer.
export function terminalExplicitHyperlinkRange(
cell: { x: number; y: number },
uri: string,
options: {
cols: number;
rows: number;
rowData: GhosttySnapshot["rowData"];
hasSameHyperlink: (x: number, y: number) => boolean;
},
): GhosttyCellRange {
const start = { ...cell };
const end = { ...cell };
if (uri.length > MAX_HYPERLINK_RANGE_URI_LENGTH) return { start, end };
let scanned = 0;
while (scanned < MAX_HYPERLINK_RANGE_CELLS) {
const previous =
start.x > 0
? { x: start.x - 1, y: start.y }
: start.y > 0 && options.rowData[start.y]?.isWrapContinuation
? { x: options.cols - 1, y: start.y - 1 }
: null;
if (!previous) break;
scanned += 1;
if (!options.hasSameHyperlink(previous.x, previous.y)) break;
start.x = previous.x;
start.y = previous.y;
}
while (scanned < MAX_HYPERLINK_RANGE_CELLS) {
const next =
end.x + 1 < options.cols
? { x: end.x + 1, y: end.y }
: end.y + 1 < options.rows && options.rowData[end.y]?.wrapsToNext
? { x: 0, y: end.y + 1 }
: null;
if (!next) break;
scanned += 1;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!options.hasSameHyperlink(next.x, next.y)) break;
end.x = next.x;
end.y = next.y;
}
return { start, end };
}

function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string {
const text = row.cells.map((cell) => cell.text || " ").join("");
return trimRight ? text.trimEnd() : text;
Expand Down Expand Up @@ -1810,7 +1862,8 @@ export class GhosttyTerminalSurface {
}

private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null {
if (!this.snapshot) return null;
const snapshot = this.snapshot;
if (!snapshot) return null;
const cell = terminalGridCellAt({
bounds: this.canvas.getBoundingClientRect(),
clientX,
Expand All @@ -1824,36 +1877,17 @@ export class GhosttyTerminalSurface {
if (!cell) return null;
const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y);
if (explicitHyperlink) {
const start = { ...cell };
const end = { ...cell };
while (true) {
const previous =
start.x > 0
? { x: start.x - 1, y: start.y }
: start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation
? { x: this.cols - 1, y: start.y - 1 }
: null;
if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break;
start.x = previous.x;
start.y = previous.y;
}
while (true) {
const next =
end.x + 1 < this.cols
? { x: end.x + 1, y: end.y }
: end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext
? { x: 0, y: end.y + 1 }
: null;
if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break;
end.x = next.x;
end.y = next.y;
}
return {
text: explicitHyperlink,
range: { start, end },
range: terminalExplicitHyperlinkRange(cell, explicitHyperlink, {
cols: this.cols,
rows: this.rows,
rowData: snapshot.rowData,
hasSameHyperlink: (x, y) => this.core.hyperlinkAt(x, y) === explicitHyperlink,
}),
};
}
return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x);
return terminalLinkAtPositionWithRange(snapshot.rowData, cell.y, cell.x);
}

private sendMouse(action: TerminalMouseAction, button: number | null, event: MouseEvent): void {
Expand Down
Loading