feat(viewer): join wrapped xterm rows for link matching

Adds joinWrappedRows/offsetToCell for reassembling a wrapped xterm
line into its full text plus offset->cell mapping, so link matching
can find paths that xterm has soft-wrapped across rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:05:06 -07:00
co-authored by Claude Opus 5.5
parent 3c1d120305
commit 4ada54ffc4
2 changed files with 114 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { joinWrappedRows, MAX_JOINED_LENGTH, offsetToCell, type RowSource } from "./xtermLineJoin";
/** rows[i] = [text, isWrapped] */
const buffer = (rows: Array<[string, boolean]>): RowSource => ({
getLine: (y) =>
rows[y] ? { isWrapped: rows[y][1], translateToString: (trim?: boolean) => (trim ? rows[y][0].trimEnd() : rows[y][0]) } : undefined,
});
describe("joinWrappedRows", () => {
it("returns a single unwrapped row as-is", () => {
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
});
it("walks up to the row that started the wrap and down through continuations", () => {
const b = buffer([
["unrelated", false],
["/workspace/very/long/pa", false],
["th/to/file.ts:12 and mo", true],
["re text", true],
["next line", false],
]);
const fromMiddle = joinWrappedRows(b, 2);
expect(fromMiddle.text).toBe("/workspace/very/long/path/to/file.ts:12 and more text");
expect(fromMiddle.firstRow).toBe(1);
expect(fromMiddle.rowStarts).toEqual([0, 23, 46]);
expect(joinWrappedRows(b, 1)).toEqual(fromMiddle);
expect(joinWrappedRows(b, 3)).toEqual(fromMiddle);
});
it("stops at the length budget", () => {
const rows: Array<[string, boolean]> = [["a".repeat(1000), false]];
for (let i = 0; i < 5; i++) rows.push(["b".repeat(1000), true]);
const j = joinWrappedRows(buffer(rows), 0);
expect(j.text.length).toBeLessThanOrEqual(MAX_JOINED_LENGTH);
expect(j.rowStarts.length).toBe(2);
});
});
describe("offsetToCell", () => {
it("maps offsets to 1-based cells on the right row", () => {
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
expect(offsetToCell(j, 0)).toEqual({ x: 1, y: 5 });
expect(offsetToCell(j, 2)).toEqual({ x: 3, y: 5 });
expect(offsetToCell(j, 3)).toEqual({ x: 1, y: 6 });
expect(offsetToCell(j, 7)).toEqual({ x: 2, y: 7 });
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Joins an xterm buffer row with its wrapped continuations.
*
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
* built package does not export — so the walk is repeated here, with the same
* 2048-character budget. Rows are read with `translateToString(true)`, which
* trims the right edge; a wrap never ends in trailing spaces xterm would keep,
* so the join is exact for the text a path can occur in.
*
* Wide characters (CJK, emoji) occupy two cells but one string index, so a
* column computed from a string offset drifts right of the glyph on such rows.
* The addon corrects this with `getCell`; v1 accepts the drift (underline
* lands a cell early; the click still resolves the same link).
*/
export const MAX_JOINED_LENGTH = 2048;
/** Minimal slice of xterm's IBuffer this needs. */
export interface RowSource {
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
}
export interface JoinedLine {
text: string;
/** 0-based index of the first buffer row that contributed. */
firstRow: number;
/** For each contributed row (in order), the string offset at which it starts. */
rowStarts: number[];
}
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
let top = row;
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
const parts: string[] = [];
let length = 0;
let y = top;
for (;;) {
const line = buffer.getLine(y);
if (!line) break;
if (y !== top && !line.isWrapped) break;
const text = line.translateToString(true);
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
parts.push(text);
length += text.length;
y++;
}
const rowStarts: number[] = [];
let offset = 0;
for (const p of parts) {
rowStarts.push(offset);
offset += p.length;
}
return { text: parts.join(""), firstRow: top, rowStarts };
}
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
let rowIdx = 0;
for (let i = 0; i < joined.rowStarts.length; i++) {
if (joined.rowStarts[i] <= offset) rowIdx = i;
}
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
}