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
119 changes: 116 additions & 3 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core";
import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
Expand Down Expand Up @@ -429,9 +429,9 @@ describe("GhosttyTerminalSurface visibility", () => {
);
});

const cell = (text: string): GhosttyCell => ({
const cell = (text: string, wide: GhosttyCell["wide"] = GHOSTTY_CELL_WIDE.narrow): GhosttyCell => ({
text,
wide: 0,
wide,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
bold: false,
Expand All @@ -443,6 +443,22 @@ const cell = (text: string): GhosttyCell => ({
selected: false,
});

const rowFromCells = (
cells: GhosttyCell[],
isWrapContinuation = false,
wrapsToNext = false,
): GhosttyRow => ({
cells,
text: cells
.map((cell) => cell.text || " ")
.join("")
.trimEnd(),
isWrapContinuation,
wrapsToNext,
});

const textCells = (text: string): GhosttyCell[] => Array.from(text, (character) => cell(character));

describe("isTerminalAltGraphText", () => {
it("defers printable AltGr output to the textarea input event", () => {
expect(
Expand Down Expand Up @@ -510,6 +526,103 @@ describe("shouldBlinkTerminalCursor", () => {
});

describe("terminalLinkAtPositionWithRange", () => {
it("keeps links contiguous across Ghostty spacer tails and preserves real blanks", () => {
const urlPrefix = "https://example.com/";
const url = `${urlPrefix}日本/report`;
const urlRow = rowFromCells([
...textCells(urlPrefix),
cell("日"),
cell("", GHOSTTY_CELL_WIDE.spacerTail),
cell("本"),
cell("", GHOSTTY_CELL_WIDE.spacerTail),
...textCells("/report"),
]);

for (const column of [urlPrefix.length, urlPrefix.length + 1, urlPrefix.length + 2]) {
expect(terminalLinkAtPositionWithRange([urlRow], 0, column)?.text).toBe(url);
}

const path = "~/日本/report.ts";
const pathStart = "~/".length;
const pathRow = rowFromCells([
...textCells("~/"),
cell("日"),
cell("", GHOSTTY_CELL_WIDE.spacerTail),
cell("本"),
cell("", GHOSTTY_CELL_WIDE.spacerTail),
...textCells("/report.ts"),
]);
expect(terminalLinkAtPositionWithRange([pathRow], 0, pathStart + 1)?.text).toBe(path);

const separatedRow = rowFromCells([
...textCells(urlPrefix),
cell("日"),
cell("", GHOSTTY_CELL_WIDE.spacerTail),
cell(""),
...textCells("next"),
]);
expect(terminalLinkAtPositionWithRange([separatedRow], 0, urlPrefix.length)?.text).toBe(
"https://example.com/日",
);
});

it("maps both cells of a trailing emoji and includes its spacer tail in the range", () => {
const prefix = "https://example.com/";
const url = `${prefix}🙂`;
const row = rowFromCells([
...textCells(prefix),
cell("🙂"),
cell("", GHOSTTY_CELL_WIDE.spacerTail),
]);

for (const column of [prefix.length, prefix.length + 1]) {
expect(terminalLinkAtPositionWithRange([row], 0, column)).toEqual({
text: url,
range: {
start: { x: 0, y: 0 },
end: { x: prefix.length + 1, y: 0 },
},
});
}
});

it("keeps spacer tails out of links split across wrapped rows", () => {
const firstRow = rowFromCells(
[...textCells("https://example.com/"), cell("日"), cell("", GHOSTTY_CELL_WIDE.spacerTail)],
false,
);
const secondRow = rowFromCells(
[cell("本"), cell("", GHOSTTY_CELL_WIDE.spacerTail), ...textCells("/report")],
true,
);
const rows = [firstRow, secondRow];

expect(terminalLinkAtPositionWithRange(rows, 0, "https://example.com/".length)?.text).toBe(
"https://example.com/日本/report",
);
expect(terminalLinkAtPositionWithRange(rows, 1, 0)?.text).toBe(
"https://example.com/日本/report",
);
});

it("does not turn a soft-wrap spacer head into a link-breaking blank", () => {
const firstRow = rowFromCells(
[...textCells("https://example.com/"), cell("", GHOSTTY_CELL_WIDE.spacerHead)],
false,
true,
);
const secondRow = rowFromCells(
[cell("日"), cell("", GHOSTTY_CELL_WIDE.spacerTail), ...textCells("/report")],
true,
);
const rows = [firstRow, secondRow];

expect(terminalLinkAtPositionWithRange(rows, 0, "https://example.com/".length)?.text).toBe(
"https://example.com/日/report",
);
expect(terminalLinkAtPositionWithRange(rows, 1, 0)?.text).toBe("https://example.com/日/report");
});

it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
const cells = [
cell("🙂"),
Expand Down
42 changes: 35 additions & 7 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { isMacPlatform } from "../../lib/utils";
import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions";
import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links";
import {
GHOSTTY_CELL_WIDE,
GhosttyTerminalCore,
type GhosttyCell,
type GhosttyScrollbar,
type GhosttySnapshot,
type GhosttyTheme,
Expand Down Expand Up @@ -231,15 +233,29 @@ export function terminalGridCellAt(options: {
};
}

function terminalCellText(cell: GhosttyCell): string {
const isSpacer =
cell.wide === GHOSTTY_CELL_WIDE.spacerTail || cell.wide === GHOSTTY_CELL_WIDE.spacerHead;
return cell.text || (isSpacer ? "" : " ");
}

function terminalCellTextLength(cell: GhosttyCell | undefined): number {
return cell ? terminalCellText(cell).length : 1;
}

function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string {
const text = row.cells.map((cell) => cell.text || " ").join("");
const text = row.cells.map(terminalCellText).join("");
return trimRight ? text.trimEnd() : text;
}

function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number {
let offset = 0;
for (let cellIndex = 0; cellIndex < column; cellIndex += 1) {
offset += row.cells[cellIndex]?.text.length || 1;
offset += terminalCellTextLength(row.cells[cellIndex]);
}
if (row.cells[column]?.wide === GHOSTTY_CELL_WIDE.spacerTail) {
const previousCell = row.cells[column - 1];
if (previousCell?.text.length) offset -= previousCell.text.length;
}
return offset;
}
Expand All @@ -262,10 +278,22 @@ function isSameTerminalLink(
);
}

function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number {
function terminalColumnAtOffset(
row: GhosttySnapshot["rowData"][number],
offset: number,
includeSpacerTail: boolean,
): number {
let textOffset = 0;
for (let column = 0; column < row.cells.length; column += 1) {
const nextOffset = terminalColumnOffset(row, column + 1);
if (offset < nextOffset) return column;
const cell = row.cells[column];
const cellLength = terminalCellTextLength(cell);
if (cellLength > 0 && offset < textOffset + cellLength) {
if (includeSpacerTail && row.cells[column + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail) {
return column + 1;
}
return column;
}
textOffset += cellLength;
}
return Math.max(0, row.cells.length - 1);
}
Expand Down Expand Up @@ -316,11 +344,11 @@ export function terminalLinkAtPositionWithRange(
text: match.text,
range: {
start: {
x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex),
x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex, false),
y: startSegment.bufferLineNumber - 1,
},
end: {
x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex),
x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex, true),
y: endSegment.bufferLineNumber - 1,
},
},
Expand Down
Loading