428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import { createRequire } from "node:module";
|
||
|
|
import test from "node:test";
|
||
|
|
import type { Terminal as XTermType } from "@xterm/xterm";
|
||
|
|
import {
|
||
|
|
getNormalizedTerminalSelection,
|
||
|
|
getTerminalSelectionForClipboard,
|
||
|
|
joinSoftWrappedRows,
|
||
|
|
trimWrittenPadding,
|
||
|
|
type SelectionBufferLine,
|
||
|
|
type SelectionTerminal,
|
||
|
|
} from "./normalizeTerminalSelection.ts";
|
||
|
|
|
||
|
|
const require = createRequire(import.meta.url);
|
||
|
|
const { Terminal: XTerm } = require("@xterm/xterm") as {
|
||
|
|
Terminal: typeof XTermType;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fake line that matches real xterm translateToString(true) semantics:
|
||
|
|
* trimRight only drops *empty* cells (trailing chars that were never written),
|
||
|
|
* not written ASCII spaces used as display padding.
|
||
|
|
*/
|
||
|
|
function makeLine(
|
||
|
|
text: string,
|
||
|
|
options: {
|
||
|
|
isWrapped?: boolean;
|
||
|
|
emptyCells?: number;
|
||
|
|
cellWidths?: number[];
|
||
|
|
/**
|
||
|
|
* Model xterm.js 6 string-cache: a no-endColumn translateToString(true)
|
||
|
|
* drops written trailing spaces via String.trimEnd().
|
||
|
|
*/
|
||
|
|
canonicalTrimEnd?: boolean;
|
||
|
|
} = {},
|
||
|
|
): SelectionBufferLine {
|
||
|
|
const emptyCells = options.emptyCells ?? 0;
|
||
|
|
// Optional per-character terminal widths (default 1). Used to model CJK.
|
||
|
|
const widths = options.cellWidths ?? Array.from(text, () => 1);
|
||
|
|
let colLength = 0;
|
||
|
|
for (let i = 0; i < text.length; i += 1) {
|
||
|
|
colLength += widths[i] ?? 1;
|
||
|
|
}
|
||
|
|
const fullCols = colLength + emptyCells;
|
||
|
|
// Map each terminal column to a char (wide chars occupy two cols; second is spacer).
|
||
|
|
const cols: string[] = [];
|
||
|
|
for (let i = 0; i < text.length; i += 1) {
|
||
|
|
const ch = text[i]!;
|
||
|
|
const w = widths[i] ?? 1;
|
||
|
|
cols.push(ch);
|
||
|
|
for (let extra = 1; extra < w; extra += 1) {
|
||
|
|
cols.push(""); // wide-char continuation column
|
||
|
|
}
|
||
|
|
}
|
||
|
|
while (cols.length < fullCols) {
|
||
|
|
cols.push("\0");
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
isWrapped: options.isWrapped ?? false,
|
||
|
|
length: fullCols,
|
||
|
|
getCell(x: number) {
|
||
|
|
if (x < 0 || x >= cols.length) return undefined;
|
||
|
|
const cell = cols[x];
|
||
|
|
if (cell === "\0" || cell === undefined) {
|
||
|
|
return { getChars: () => "", getCode: () => 0, getWidth: () => 1 };
|
||
|
|
}
|
||
|
|
if (cell === "") {
|
||
|
|
// Wide-character continuation column.
|
||
|
|
return { getChars: () => "", getCode: () => 0, getWidth: () => 0 };
|
||
|
|
}
|
||
|
|
const width = cell === "中" || /[\u3400-\u9fff]/u.test(cell) ? 2 : 1;
|
||
|
|
return {
|
||
|
|
getChars: () => cell,
|
||
|
|
getCode: () => cell.codePointAt(0) ?? 0,
|
||
|
|
getWidth: () => width,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
translateToString(trimRight = false, startColumn = 0, endColumn?: number) {
|
||
|
|
const endSpecified = arguments.length >= 3;
|
||
|
|
let end = Math.max(startColumn, Math.min(endColumn ?? fullCols, fullCols));
|
||
|
|
if (trimRight) {
|
||
|
|
while (end > startColumn && (cols[end - 1] === "\0" || cols[end - 1] === "")) {
|
||
|
|
end -= 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let result = "";
|
||
|
|
for (let c = Math.max(0, startColumn); c < end; c += 1) {
|
||
|
|
const cell = cols[c];
|
||
|
|
if (cell && cell !== "\0") result += cell;
|
||
|
|
}
|
||
|
|
if (trimRight && options.canonicalTrimEnd && !endSpecified) {
|
||
|
|
return result.trimEnd();
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeTerm(
|
||
|
|
lines: Array<{
|
||
|
|
text: string;
|
||
|
|
isWrapped?: boolean;
|
||
|
|
emptyCells?: number;
|
||
|
|
cellWidths?: number[];
|
||
|
|
canonicalTrimEnd?: boolean;
|
||
|
|
}>,
|
||
|
|
range: { start: { x: number; y: number }; end: { x: number; y: number } } | null,
|
||
|
|
options: {
|
||
|
|
rawSelection?: string;
|
||
|
|
columnSelect?: boolean;
|
||
|
|
} = {},
|
||
|
|
): SelectionTerminal {
|
||
|
|
const bufferLines = lines.map((line) =>
|
||
|
|
makeLine(line.text, {
|
||
|
|
isWrapped: line.isWrapped,
|
||
|
|
emptyCells: line.emptyCells,
|
||
|
|
cellWidths: line.cellWidths,
|
||
|
|
canonicalTrimEnd: line.canonicalTrimEnd,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
getSelection: () => options.rawSelection ?? "",
|
||
|
|
getSelectionPosition: () => range,
|
||
|
|
buffer: {
|
||
|
|
active: {
|
||
|
|
getLine: (y) => bufferLines[y],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
_core: options.columnSelect
|
||
|
|
? { _selectionService: { _activeSelectionMode: 3 } }
|
||
|
|
: { _selectionService: { _activeSelectionMode: 0 } },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("trimWrittenPadding removes written trailing spaces but keeps internal ones", () => {
|
||
|
|
assert.equal(trimWrittenPadding("hello "), "hello");
|
||
|
|
assert.equal(trimWrittenPadding(" hello world "), " hello world");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows keeps a single trailing space as a word separator", () => {
|
||
|
|
assert.equal(joinSoftWrappedRows("hello ", "world"), "hello world");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows concatenates mid-word wraps tightly", () => {
|
||
|
|
assert.equal(joinSoftWrappedRows("hel", "lo world"), "hello world");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows does not invent spaces inside URL/path tokens", () => {
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("https://example.com/very/long/ ", "path"),
|
||
|
|
"https://example.com/very/long/path",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows does not invent spaces before CJK punctuation", () => {
|
||
|
|
assert.equal(joinSoftWrappedRows("你好 ", ",世界"), "你好,世界");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows collapses multi-space prose padding to one space", () => {
|
||
|
|
// Pi/TUI word-wrapped prose: padding between words must not glue them.
|
||
|
|
assert.equal(joinSoftWrappedRows("the most ", "reliable"), "the most reliable");
|
||
|
|
assert.equal(joinSoftWrappedRows("shifts ", "across"), "shifts across");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows does not invent spaces between CJK characters", () => {
|
||
|
|
assert.equal(joinSoftWrappedRows("最 ", "稳"), "最稳");
|
||
|
|
// Single pad cell must not bypass the CJK check.
|
||
|
|
assert.equal(joinSoftWrappedRows("最 ", "稳"), "最稳");
|
||
|
|
assert.equal(joinSoftWrappedRows("한 ", "글"), "한글");
|
||
|
|
assert.equal(joinSoftWrappedRows("你好。 ", "世界"), "你好。世界");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows keeps a space after sentence-ending punctuation", () => {
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("First sentence. ", "Next sentence"),
|
||
|
|
"First sentence. Next sentence",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows only uses the trailing token for URL detection", () => {
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("See https://x.test for more ", "details"),
|
||
|
|
"See https://x.test for more details",
|
||
|
|
);
|
||
|
|
// Single trailing space after a URL is a real word separator.
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("See https://example.com ", "today"),
|
||
|
|
"See https://example.com today",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows keeps path separators attached across wraps", () => {
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("https://example.com/very/long/ ", "path"),
|
||
|
|
"https://example.com/very/long/path",
|
||
|
|
);
|
||
|
|
// Complete URL + padding + next word stays two words (not mid-token glue).
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("See https://example.com ", "today"),
|
||
|
|
"See https://example.com today",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows preserves Windows paths and URL query delimiters", () => {
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("C:\\Users\\alice\\ ", "file.txt"),
|
||
|
|
"C:\\Users\\alice\\file.txt",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("https://example.com/path ", "?q=netcatty"),
|
||
|
|
"https://example.com/path?q=netcatty",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("https://example.com/search? ", "q=netcatty"),
|
||
|
|
"https://example.com/search?q=netcatty",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows keeps sentence break after a URL", () => {
|
||
|
|
assert.equal(
|
||
|
|
joinSoftWrappedRows("See https://example.com. ", "Next sentence"),
|
||
|
|
"See https://example.com. Next sentence",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joinSoftWrappedRows keeps hyphenated words intact at the hyphen", () => {
|
||
|
|
assert.equal(joinSoftWrappedRows("state- ", "of-the-art"), "state-of-the-art");
|
||
|
|
// Complete flag + next word keeps a boundary.
|
||
|
|
assert.equal(joinSoftWrappedRows("Use --verbose ", "to inspect"), "Use --verbose to inspect");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("preserves partial trailing spaces after wide characters using column ends", () => {
|
||
|
|
// "中 X" with 中 width 2 → columns: [中][ ][ ][ ][X] roughly.
|
||
|
|
// Select through the spaces after 中 but before X.
|
||
|
|
const term = makeTerm(
|
||
|
|
[{ text: "中 X", cellWidths: [2, 1, 1, 1] }],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 4, y: 0 } },
|
||
|
|
);
|
||
|
|
// end.x=4 is before content end column of X; keep selected spaces.
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "中 ");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("joins soft-wrapped rows and collapses prose padding to one space", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "Pi: use /copy is the most " },
|
||
|
|
{ text: "reliable option ", isWrapped: true },
|
||
|
|
{ text: "next hard line " },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 28, y: 2 } },
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
getNormalizedTerminalSelection(term),
|
||
|
|
"Pi: use /copy is the most reliable option\nnext hard line",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("keeps a single trailing space as a word-boundary soft wrap", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "hello " },
|
||
|
|
{ text: "world ", isWrapped: true },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 8, y: 1 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "hello world");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("preserves hard line breaks between non-wrapped rows while trimming padding", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "line one " },
|
||
|
|
{ text: "line two " },
|
||
|
|
{ text: "line three " },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 11, y: 2 } },
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "line one\nline two\nline three");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("preserves explicitly selected trailing spaces on a partial last row", () => {
|
||
|
|
// "abc def" — select columns 0..5 → "abc " must keep the spaces.
|
||
|
|
const term = makeTerm(
|
||
|
|
[{ text: "abc def" }],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "abc ");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("empty-cell trim from xterm still applies before written-space trim", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[{ text: "hello", emptyCells: 10 }],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 15, y: 0 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "hello");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("respects partial column selection on first and last rows", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "xxhello worldyy" },
|
||
|
|
{ text: "continued here!", isWrapped: true },
|
||
|
|
],
|
||
|
|
{ start: { x: 2, y: 0 }, end: { x: 10, y: 1 } },
|
||
|
|
);
|
||
|
|
|
||
|
|
// Last-row end.x=10 selects "continued " (including the space); keep it.
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "hello worldyycontinued ");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("falls back to getSelection when position is unavailable", () => {
|
||
|
|
const term = makeTerm([{ text: "abc" }], null, { rawSelection: "fallback text" });
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "fallback text");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("getTerminalSelectionForClipboard respects normalize flag", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "hello " },
|
||
|
|
{ text: "world ", isWrapped: true },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 8, y: 1 } },
|
||
|
|
{ rawSelection: "hello \nworld " },
|
||
|
|
);
|
||
|
|
assert.equal(getTerminalSelectionForClipboard(term, true), "hello world");
|
||
|
|
assert.equal(getTerminalSelectionForClipboard(term, false), "hello \nworld ");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("returns empty string for empty range and normalizes inverted ranges", () => {
|
||
|
|
const empty = makeTerm([{ text: "abc" }], { start: { x: 1, y: 0 }, end: { x: 1, y: 0 } });
|
||
|
|
assert.equal(getNormalizedTerminalSelection(empty), "");
|
||
|
|
|
||
|
|
const inverted = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "alpha " },
|
||
|
|
{ text: "beta " },
|
||
|
|
],
|
||
|
|
{ start: { x: 6, y: 1 }, end: { x: 0, y: 0 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(inverted), "alpha\nbeta");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("handles multi-row soft wrap chains", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "aaa" },
|
||
|
|
{ text: "bbb", isWrapped: true },
|
||
|
|
{ text: "ccc", isWrapped: true },
|
||
|
|
{ text: "ddd" },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 3, y: 3 } },
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "aaabbbccc\nddd");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("preserves rectangular column selection including right-edge spaces", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "ab efghij" },
|
||
|
|
{ text: "01 56789x" },
|
||
|
|
{ text: "AB EFGHIJ" },
|
||
|
|
],
|
||
|
|
{ start: { x: 2, y: 0 }, end: { x: 5, y: 2 } },
|
||
|
|
{ columnSelect: true },
|
||
|
|
);
|
||
|
|
|
||
|
|
// Columns 2..5 include the intentional spaces.
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), " e\n 5\n E");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("converts non-breaking spaces to regular spaces", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[{ text: "hello\u00a0world " }],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 13, y: 0 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "hello world");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("soft-wrapped CJK with padding joins without inserted spaces", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "Pi: 用 /copy 最 " },
|
||
|
|
{ text: "稳 ", isWrapped: true },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 18, y: 1 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "Pi: 用 /copy 最稳");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("keeps a wrap-boundary space that canonical translateToString trimEnd would drop", () => {
|
||
|
|
const term = makeTerm(
|
||
|
|
[
|
||
|
|
{ text: "|hbase ", canonicalTrimEnd: true },
|
||
|
|
{ text: "shell", isWrapped: true, canonicalTrimEnd: true },
|
||
|
|
],
|
||
|
|
{ start: { x: 0, y: 0 }, end: { x: 5, y: 1 } },
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(term), "|hbase shell");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("real xterm keeps a wrap-boundary space after an untrimmed string-cache prime", async () => {
|
||
|
|
const xterm = new XTerm({ cols: 20, rows: 8, scrollback: 20, allowProposedApi: true });
|
||
|
|
try {
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
xterm.write("12345678901234hbase shell\r\n", resolve);
|
||
|
|
});
|
||
|
|
// Prompt detector / autocomplete / keyword highlight all call this first.
|
||
|
|
xterm.buffer.active.getLine(0)?.translateToString(false);
|
||
|
|
|
||
|
|
const wrapped: SelectionTerminal = {
|
||
|
|
getSelection: () => xterm.getSelection(),
|
||
|
|
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 1 } }),
|
||
|
|
buffer: xterm.buffer,
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
xterm.buffer.active.getLine(0)?.translateToString(true),
|
||
|
|
"12345678901234hbase",
|
||
|
|
);
|
||
|
|
assert.equal(getNormalizedTerminalSelection(wrapped), "12345678901234hbase shell");
|
||
|
|
} finally {
|
||
|
|
xterm.dispose();
|
||
|
|
}
|
||
|
|
});
|