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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user