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