diff --git a/app/src/lib/xtermLineJoin.test.ts b/app/src/lib/xtermLineJoin.test.ts new file mode 100644 index 0000000..b50d08b --- /dev/null +++ b/app/src/lib/xtermLineJoin.test.ts @@ -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 }); + }); +}); diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts new file mode 100644 index 0000000..1be6501 --- /dev/null +++ b/app/src/lib/xtermLineJoin.ts @@ -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 }; +}