Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
877 lines
25 KiB
TypeScript
877 lines
25 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
clampAutocompletePopupGeometry,
|
|
computeAutocompletePopupPlacement,
|
|
nextAutocompletePopupAnchorViewport,
|
|
resolveAutocompleteAnchorInViewport,
|
|
resolveAutocompleteClampViewport,
|
|
resolveAutocompleteCommandStartRow,
|
|
resolveAutocompleteCursorCell,
|
|
resolveAutocompleteCursorColumn,
|
|
resolveAutocompletePopupAnchorInViewport,
|
|
resolvePreservedSuggestionIndex,
|
|
type PopupPlacementInput,
|
|
} from "./autocomplete/terminalAutocompleteLayout.ts";
|
|
|
|
const baseInput: PopupPlacementInput = {
|
|
anchorTop: 100,
|
|
anchorBottom: 120,
|
|
anchorLeft: 200,
|
|
viewportWidth: 1200,
|
|
viewportHeight: 800,
|
|
desiredHeight: 232,
|
|
totalWidth: 400,
|
|
maxHeight: 240,
|
|
anchorGap: 8,
|
|
viewportPadding: 8,
|
|
expandUpwardHint: false,
|
|
};
|
|
|
|
test("renders downward with full height when there is ample room below", () => {
|
|
const p = computeAutocompletePopupPlacement(baseInput);
|
|
assert.equal(p.renderUpward, false);
|
|
assert.equal(p.top, baseInput.anchorBottom + baseInput.anchorGap); // 128
|
|
assert.equal(p.maxHeight, 240);
|
|
// Whole popup stays within the viewport vertically.
|
|
assert.ok(p.top + p.maxHeight <= baseInput.viewportHeight - baseInput.viewportPadding);
|
|
});
|
|
|
|
test("flips upward when the cursor sits at the very bottom of the viewport", () => {
|
|
// Anchor line flush with the viewport bottom — classic "typing at the
|
|
// bottom of the terminal" case from the bug report.
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorTop: 780,
|
|
anchorBottom: 800,
|
|
});
|
|
assert.equal(p.renderUpward, true);
|
|
// Bottom edge of the *rendered content* sits just above the input line.
|
|
// p.maxHeight is only the scroll budget; actual content is min(budget,desired).
|
|
const contentHeight = Math.min(p.maxHeight, baseInput.desiredHeight);
|
|
assert.ok(p.top + contentHeight <= 780 - baseInput.anchorGap + 0.001);
|
|
assert.ok(p.top >= baseInput.viewportPadding);
|
|
});
|
|
|
|
test("never lets a downward popup overflow the viewport bottom", () => {
|
|
// A little room below but not enough for the full desired height: the popup
|
|
// must shrink + clamp so its bottom never crosses the viewport edge.
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorTop: 600,
|
|
anchorBottom: 620,
|
|
desiredHeight: 232,
|
|
});
|
|
const bottom = p.top + p.maxHeight;
|
|
assert.ok(
|
|
bottom <= baseInput.viewportHeight - baseInput.viewportPadding + 0.001,
|
|
`popup bottom ${bottom} should stay within viewport`,
|
|
);
|
|
});
|
|
|
|
test("clamps height to the larger side when neither side fully fits", () => {
|
|
// Short viewport: cursor in the middle, not enough above or below for 232px.
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
viewportHeight: 300,
|
|
anchorTop: 140,
|
|
anchorBottom: 160,
|
|
desiredHeight: 232,
|
|
});
|
|
// More space below (300-160-16=124) than above (140-16=124 ~ tie) — either
|
|
// way the rendered height must fit the chosen side and stay on-screen.
|
|
const contentHeight = Math.min(p.maxHeight, 232);
|
|
if (p.renderUpward) {
|
|
assert.ok(p.top >= baseInput.viewportPadding);
|
|
assert.ok(p.top + contentHeight <= 160 - baseInput.anchorGap + 0.001);
|
|
} else {
|
|
assert.ok(p.top + contentHeight <= 300 - baseInput.viewportPadding + 0.001);
|
|
}
|
|
assert.ok(p.maxHeight > 0);
|
|
});
|
|
|
|
test("forceExpandUpward keeps the popup above the command-start when below fully fits", () => {
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorTop: 100,
|
|
anchorBottom: 120,
|
|
desiredHeight: 80,
|
|
expandUpwardHint: true,
|
|
forceExpandUpward: true,
|
|
});
|
|
assert.equal(p.renderUpward, true, "must not flip below the command-start anchor");
|
|
const contentHeight = Math.min(p.maxHeight, 80);
|
|
assert.ok(p.top + contentHeight <= 100 - baseInput.anchorGap + 0.001);
|
|
});
|
|
|
|
test("honors the upward hint to break ties when neither side fits", () => {
|
|
const shared = {
|
|
...baseInput,
|
|
viewportHeight: 300,
|
|
anchorTop: 150,
|
|
anchorBottom: 170,
|
|
desiredHeight: 232,
|
|
};
|
|
const withHint = computeAutocompletePopupPlacement({ ...shared, expandUpwardHint: true });
|
|
// spaceAbove = 150-16 = 134; min(spaceBelow,80) -> spaceBelow=300-170-16=114, min=80
|
|
// 134 >= 80 so the hint flips it upward.
|
|
assert.equal(withHint.renderUpward, true);
|
|
});
|
|
|
|
test("clamps the left edge so the FULL assembly (with sub-dir panels) fits", () => {
|
|
// Cursor near the right edge, popup widened by cascading sub-dir panels.
|
|
// The whole assembly (1100px wide) must slide left to stay on-screen.
|
|
const totalWidth = 1100; // main 400 + panels + detail
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorLeft: 1150,
|
|
totalWidth,
|
|
});
|
|
assert.ok(p.left >= baseInput.viewportPadding);
|
|
assert.ok(
|
|
p.left + totalWidth <= baseInput.viewportWidth - baseInput.viewportPadding + 0.001,
|
|
`right edge ${p.left + totalWidth} should stay within viewport`,
|
|
);
|
|
});
|
|
|
|
test("pins to the left padding when the assembly is wider than the viewport", () => {
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorLeft: 600,
|
|
viewportWidth: 500,
|
|
totalWidth: 900,
|
|
});
|
|
// Can't fit no matter what — keep the primary list visible at the left edge.
|
|
assert.equal(p.left, baseInput.viewportPadding);
|
|
});
|
|
|
|
test("does not shift left when the popup already fits at the cursor", () => {
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorLeft: 200,
|
|
totalWidth: 400,
|
|
});
|
|
assert.equal(p.left, 200);
|
|
});
|
|
|
|
test("does not shift left for detail tooltips when clampWidth excludes them", () => {
|
|
const withDetailClamp = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorLeft: 750,
|
|
totalWidth: 684,
|
|
clampWidth: 400,
|
|
});
|
|
const withFullClamp = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorLeft: 750,
|
|
totalWidth: 684,
|
|
});
|
|
assert.equal(withDetailClamp.left, 750);
|
|
assert.ok(withFullClamp.left < withDetailClamp.left);
|
|
});
|
|
|
|
test("clamps within a split terminal pane instead of the full window", () => {
|
|
const pane = { left: 700, top: 80, width: 680, height: 520 };
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorLeft: 1180,
|
|
totalWidth: 1100,
|
|
clampWidth: 400,
|
|
clampViewport: pane,
|
|
viewportWidth: pane.width,
|
|
viewportHeight: pane.height,
|
|
});
|
|
assert.ok(p.left >= pane.left + baseInput.viewportPadding);
|
|
assert.ok(p.left + 400 <= pane.left + pane.width - baseInput.viewportPadding + 0.001);
|
|
});
|
|
|
|
test("final popup geometry clamps the actual rendered width", () => {
|
|
const pane = { left: 700, top: 80, width: 680, height: 520 };
|
|
const geometry = clampAutocompletePopupGeometry({
|
|
left: 970,
|
|
top: 180,
|
|
width: 520,
|
|
height: 220,
|
|
clampViewport: pane,
|
|
viewportPadding: 8,
|
|
});
|
|
|
|
assert.equal(geometry.left, 852);
|
|
assert.ok(geometry.left >= pane.left + 8);
|
|
assert.ok(geometry.left + 520 <= pane.left + pane.width - 8);
|
|
});
|
|
|
|
test("final popup geometry clamps the actual rendered height", () => {
|
|
const pane = { left: 700, top: 80, width: 680, height: 520 };
|
|
const geometry = clampAutocompletePopupGeometry({
|
|
left: 760,
|
|
top: 470,
|
|
width: 360,
|
|
height: 180,
|
|
clampViewport: pane,
|
|
viewportPadding: 8,
|
|
});
|
|
|
|
assert.equal(geometry.top, 412);
|
|
assert.ok(geometry.top >= pane.top + 8);
|
|
assert.ok(geometry.top + 180 <= pane.top + pane.height - 8);
|
|
});
|
|
|
|
test("resolveAutocompleteAnchorInViewport uses the xterm screen rect in split panes", () => {
|
|
const cellWidth = 5;
|
|
const cellHeight = 200 / 24;
|
|
const screen = {
|
|
clientWidth: 400,
|
|
clientHeight: 200,
|
|
getBoundingClientRect: () => ({
|
|
left: 120,
|
|
top: 260,
|
|
right: 520,
|
|
bottom: 460,
|
|
width: 400,
|
|
height: 200,
|
|
x: 120,
|
|
y: 260,
|
|
toJSON: () => ({}),
|
|
}),
|
|
};
|
|
const container = {
|
|
querySelector: (selector: string) => (selector === ".xterm-screen" ? screen : null),
|
|
} as unknown as HTMLElement;
|
|
|
|
const term = {
|
|
element: {
|
|
querySelector: () => null,
|
|
},
|
|
cols: 80,
|
|
rows: 24,
|
|
buffer: {
|
|
active: {
|
|
cursorX: 10,
|
|
cursorY: 20,
|
|
baseY: 0,
|
|
getLine: () => ({ isWrapped: false }),
|
|
},
|
|
},
|
|
_core: {
|
|
_renderService: {
|
|
dimensions: {
|
|
css: {
|
|
cell: { width: cellWidth, height: cellHeight },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const anchor = resolveAutocompleteAnchorInViewport(term as never, container, 5, 10);
|
|
assert.equal(anchor.anchorLeft, 120 + cellWidth * 10);
|
|
assert.equal(anchor.anchorTop, 260 + cellHeight * 20);
|
|
assert.equal(anchor.anchorBottom, 260 + cellHeight * 21);
|
|
});
|
|
|
|
test("resolveAutocompleteCursorColumn prefers prompt-aligned column when xterm lags", () => {
|
|
const term = {
|
|
buffer: {
|
|
active: {
|
|
cursorX: 0,
|
|
cursorY: 22,
|
|
baseY: 0,
|
|
getLine: () => ({
|
|
isWrapped: false,
|
|
translateToString: () => "root@host:~# d",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
const column = resolveAutocompleteCursorColumn(term as never, {
|
|
promptText: "root@host:~# ",
|
|
userInput: "d",
|
|
});
|
|
assert.equal(column, "root@host:~# ".length + 1);
|
|
});
|
|
|
|
test("resolveAutocompleteCursorColumn counts wide glyphs as two cells for pre-echo input", () => {
|
|
const term = {
|
|
cols: 80,
|
|
buffer: {
|
|
active: {
|
|
cursorX: 2,
|
|
cursorY: 0,
|
|
baseY: 0,
|
|
getLine: () => ({
|
|
isWrapped: false,
|
|
translateToString: () => "$ ",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
const column = resolveAutocompleteCursorColumn(term as never, {
|
|
promptText: "$ ",
|
|
userInput: "部署",
|
|
});
|
|
// "$ " = 2 cells, each CJK ideograph = 2 cells → column 6 (not char-length 4).
|
|
assert.equal(column, 6);
|
|
});
|
|
|
|
test("resolveAutocompleteCursorCell wraps synthetic pre-echo columns onto the next row", () => {
|
|
const term = {
|
|
cols: 10,
|
|
rows: 24,
|
|
buffer: {
|
|
active: {
|
|
cursorX: 8,
|
|
cursorY: 0,
|
|
baseY: 0,
|
|
getLine: () => ({
|
|
isWrapped: false,
|
|
translateToString: () => "$ ",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
const cell = resolveAutocompleteCursorCell(term as never, {
|
|
promptText: "$ ",
|
|
// Four wide cells after a prompt ending at column 8 → raw column 12.
|
|
userInput: "部署",
|
|
});
|
|
assert.equal(cell.column, 2);
|
|
assert.equal(cell.row, 1);
|
|
});
|
|
|
|
test("resolveAutocompleteCursorCell clamps a bottom-row wrap to the visible grid", () => {
|
|
// Pending wrap from the last visible row would predict row === term.rows,
|
|
// but xterm scrolls and keeps the cursor on term.rows - 1.
|
|
const term = {
|
|
cols: 10,
|
|
rows: 24,
|
|
buffer: {
|
|
active: {
|
|
cursorX: 8,
|
|
cursorY: 23,
|
|
baseY: 0,
|
|
getLine: () => ({
|
|
isWrapped: false,
|
|
translateToString: () => "$ ",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
const cell = resolveAutocompleteCursorCell(term as never, {
|
|
promptText: "$ ",
|
|
userInput: "部署",
|
|
});
|
|
assert.equal(cell.column, 2);
|
|
assert.equal(cell.row, 23);
|
|
});
|
|
|
|
test("resolveAutocompleteCursorCell advances past a partial wrap using unechoed userInput", () => {
|
|
// cols=10: "$ docker c" | "o|" — echo wrapped after a prefix while the
|
|
// buffered input is still the full "docker compose". Anchoring at the
|
|
// live wrapped cell (col 1) would leave the popup behind.
|
|
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
|
|
0: { text: "$ docker c" },
|
|
1: { isWrapped: true, text: "o" },
|
|
};
|
|
const term = {
|
|
cols: 10,
|
|
rows: 24,
|
|
buffer: {
|
|
active: {
|
|
cursorX: 1,
|
|
cursorY: 1,
|
|
baseY: 0,
|
|
getLine: (y: number) => {
|
|
const row = lines[y];
|
|
if (!row) return undefined;
|
|
return {
|
|
isWrapped: row.isWrapped,
|
|
translateToString: () => row.text,
|
|
};
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const cell = resolveAutocompleteCursorCell(term as never, {
|
|
promptText: "$ ",
|
|
userInput: "docker compose",
|
|
});
|
|
// "$ " + "docker compose" = 16 cells → col 6 on row 1.
|
|
assert.equal(cell.column, 6);
|
|
assert.equal(cell.row, 1);
|
|
});
|
|
|
|
test("resolveAutocompleteCursorColumn counts ZWJ emoji graphemes as one wide cluster", () => {
|
|
const term = {
|
|
buffer: {
|
|
active: {
|
|
cursorX: 2,
|
|
cursorY: 0,
|
|
baseY: 0,
|
|
getLine: () => ({
|
|
isWrapped: false,
|
|
translateToString: () => "$ ",
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
const column = resolveAutocompleteCursorColumn(term as never, {
|
|
promptText: "$ ",
|
|
// Technologist emoji is one grapheme (👨 + ZWJ + 💻); xterm paints it
|
|
// as two cells, not five code-point widths.
|
|
userInput: "👨💻",
|
|
});
|
|
assert.equal(column, 4);
|
|
});
|
|
|
|
test("short popup flips upward when the cursor is at the bottom of the screen", () => {
|
|
// Regression for issue #1710: a *short* suggestion list must flip up when
|
|
// the anchor line is the last visible row, even if there is a tiny positive
|
|
// "phantom" space below the screen rect.
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorTop: 772,
|
|
anchorBottom: 792,
|
|
desiredHeight: 64, // 2 short items
|
|
maxHeight: 240,
|
|
});
|
|
assert.equal(p.renderUpward, true, "short popup should flip upward at bottom row");
|
|
assert.ok(p.top + Math.min(p.maxHeight, 64) <= 772 - baseInput.anchorGap + 0.001,
|
|
"rendered popup should stay above the anchor line");
|
|
assert.ok(p.top >= baseInput.viewportPadding,
|
|
"popup should not be clipped above the viewport");
|
|
});
|
|
|
|
test("clamps a downward popup back inside the terminal when the anchor is above the viewport", () => {
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorTop: -60,
|
|
anchorBottom: -40,
|
|
desiredHeight: 96,
|
|
maxHeight: 240,
|
|
expandUpwardHint: false,
|
|
});
|
|
const contentHeight = Math.min(p.maxHeight, 96);
|
|
|
|
assert.equal(p.renderUpward, false);
|
|
assert.ok(
|
|
p.top >= baseInput.viewportPadding,
|
|
`popup top ${p.top} should stay inside the terminal viewport`,
|
|
);
|
|
assert.ok(
|
|
p.top + contentHeight <= baseInput.viewportHeight - baseInput.viewportPadding + 0.001,
|
|
`popup bottom ${p.top + contentHeight} should stay inside the terminal viewport`,
|
|
);
|
|
});
|
|
|
|
test("clamps an upward popup back inside the terminal when the anchor is below the viewport", () => {
|
|
const p = computeAutocompletePopupPlacement({
|
|
...baseInput,
|
|
anchorTop: 860,
|
|
anchorBottom: 880,
|
|
desiredHeight: 96,
|
|
maxHeight: 240,
|
|
expandUpwardHint: true,
|
|
});
|
|
const contentHeight = Math.min(p.maxHeight, 96);
|
|
|
|
assert.equal(p.renderUpward, true);
|
|
assert.ok(
|
|
p.top >= baseInput.viewportPadding,
|
|
`popup top ${p.top} should stay inside the terminal viewport`,
|
|
);
|
|
assert.ok(
|
|
p.top + contentHeight <= baseInput.viewportHeight - baseInput.viewportPadding + 0.001,
|
|
`popup bottom ${p.top + contentHeight} should stay inside the terminal viewport`,
|
|
);
|
|
});
|
|
|
|
test("resolveAutocompleteClampViewport clamps to the xterm screen rect", () => {
|
|
const container = {
|
|
closest: () => null,
|
|
querySelector: (selector: string) =>
|
|
selector === ".xterm-screen"
|
|
? {
|
|
getBoundingClientRect: () => ({
|
|
left: 100,
|
|
top: 50,
|
|
width: 600,
|
|
height: 400,
|
|
right: 700,
|
|
bottom: 450,
|
|
x: 100,
|
|
y: 50,
|
|
toJSON: () => ({}),
|
|
}),
|
|
}
|
|
: null,
|
|
getBoundingClientRect: () => ({
|
|
left: 100,
|
|
top: 50,
|
|
width: 600,
|
|
height: 410, // 10px taller than the screen
|
|
right: 700,
|
|
bottom: 460,
|
|
x: 100,
|
|
y: 50,
|
|
toJSON: () => ({}),
|
|
}),
|
|
} as unknown as HTMLElement;
|
|
|
|
const clamp = resolveAutocompleteClampViewport(container);
|
|
assert.equal(clamp.top, 50);
|
|
assert.equal(clamp.height, 400, "clamp height should match the screen, not the taller container");
|
|
assert.equal(clamp.width, 600);
|
|
});
|
|
|
|
test("resolveAutocompleteClampViewport prefers the visible terminal screen over the split pane", () => {
|
|
const pane = {
|
|
getAttribute: (name: string) => (name === "data-section" ? "terminal-split-pane" : null),
|
|
getBoundingClientRect: () => ({
|
|
left: 20,
|
|
top: 30,
|
|
width: 500,
|
|
height: 300,
|
|
right: 520,
|
|
bottom: 330,
|
|
x: 20,
|
|
y: 30,
|
|
toJSON: () => ({}),
|
|
}),
|
|
};
|
|
const screen = {
|
|
getBoundingClientRect: () => ({
|
|
left: 25,
|
|
top: 35,
|
|
width: 490,
|
|
height: 280,
|
|
right: 515,
|
|
bottom: 315,
|
|
x: 25,
|
|
y: 35,
|
|
toJSON: () => ({}),
|
|
}),
|
|
};
|
|
const container = {
|
|
closest: (selector: string) =>
|
|
selector === '[data-section="terminal-split-pane"]' ? (pane as never) : null,
|
|
querySelector: (selector: string) =>
|
|
selector === ".xterm-screen" ? (screen as never) : null,
|
|
getBoundingClientRect: () => ({
|
|
left: 20,
|
|
top: 30,
|
|
width: 500,
|
|
height: 310,
|
|
right: 520,
|
|
bottom: 340,
|
|
x: 20,
|
|
y: 30,
|
|
toJSON: () => ({}),
|
|
}),
|
|
} as unknown as HTMLElement;
|
|
|
|
const clamp = resolveAutocompleteClampViewport(container);
|
|
assert.equal(clamp.left, 25, "screen left should win");
|
|
assert.equal(clamp.top, 35, "screen top should win");
|
|
assert.equal(clamp.width, 490, "screen width should win");
|
|
assert.equal(clamp.height, 280, "screen height should win");
|
|
});
|
|
|
|
test("resolveAutocompleteAnchorInViewport ignores the helper textarea horizontal position", () => {
|
|
const cellWidth = 9;
|
|
const cellHeight = 17;
|
|
const screen = {
|
|
clientWidth: 720,
|
|
clientHeight: 408,
|
|
getBoundingClientRect: () => ({
|
|
left: 640,
|
|
top: 180,
|
|
right: 1360,
|
|
bottom: 588,
|
|
width: 720,
|
|
height: 408,
|
|
x: 640,
|
|
y: 180,
|
|
toJSON: () => ({}),
|
|
}),
|
|
};
|
|
const textarea = {
|
|
getBoundingClientRect: () => ({
|
|
left: 640,
|
|
top: 500,
|
|
right: 1360,
|
|
bottom: 517,
|
|
width: 720,
|
|
height: 17,
|
|
x: 640,
|
|
y: 500,
|
|
toJSON: () => ({}),
|
|
}),
|
|
};
|
|
const container = {
|
|
querySelector: (selector: string) => {
|
|
if (selector === ".xterm-screen") return screen;
|
|
if (selector === "textarea.xterm-helper-textarea") return textarea;
|
|
return null;
|
|
},
|
|
} as unknown as HTMLElement;
|
|
|
|
const cursorColumn = "root@RainYun-0tWTeTRw:~# ".length + 1;
|
|
const term = {
|
|
element: {
|
|
querySelector: () => null,
|
|
},
|
|
cols: 80,
|
|
rows: 24,
|
|
buffer: {
|
|
active: {
|
|
cursorX: 0,
|
|
cursorY: 22,
|
|
baseY: 0,
|
|
getLine: () => ({ isWrapped: false }),
|
|
},
|
|
},
|
|
_core: {
|
|
_renderService: {
|
|
dimensions: {
|
|
css: {
|
|
cell: { width: cellWidth, height: cellHeight },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const anchor = resolveAutocompleteAnchorInViewport(
|
|
term as never,
|
|
container,
|
|
5,
|
|
cursorColumn,
|
|
);
|
|
assert.equal(anchor.anchorLeft, 640 + cellWidth * cursorColumn);
|
|
assert.notEqual(anchor.anchorLeft, textarea.getBoundingClientRect().left);
|
|
});
|
|
|
|
function suggestion(partial: {
|
|
text: string;
|
|
source: "history" | "path" | "snippet" | "fig";
|
|
score?: number;
|
|
displayText?: string;
|
|
fileType?: "file" | "directory" | "symlink";
|
|
}) {
|
|
return {
|
|
displayText: partial.displayText ?? partial.text,
|
|
score: partial.score ?? 1,
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
test("resolvePreservedSuggestionIndex keeps highlight after late path reorder", () => {
|
|
const previous = [
|
|
suggestion({ text: "alpha", source: "history", score: 900 }),
|
|
suggestion({ text: "bravo/", source: "history", score: 800 }),
|
|
];
|
|
const next = [
|
|
suggestion({ text: "bravo/", source: "path", score: 750, fileType: "directory" }),
|
|
suggestion({ text: "alpha", source: "history", score: 900 }),
|
|
suggestion({ text: "charlie", source: "path", score: 700, fileType: "file" }),
|
|
];
|
|
assert.equal(resolvePreservedSuggestionIndex(previous, 1, next), 0);
|
|
});
|
|
|
|
test("resolvePreservedSuggestionIndex returns -1 when selection is absent or dropped", () => {
|
|
const previous = [suggestion({ text: "gone", source: "history" })];
|
|
const next = [suggestion({ text: "kept", source: "path", fileType: "file" })];
|
|
assert.equal(resolvePreservedSuggestionIndex(previous, -1, next), -1);
|
|
assert.equal(resolvePreservedSuggestionIndex(previous, 0, next), -1);
|
|
});
|
|
|
|
function makeWrappedCommandTerm(options: {
|
|
cursorY: number;
|
|
baseY: number;
|
|
viewportY: number;
|
|
wrapped: boolean;
|
|
rows?: number;
|
|
cols?: number;
|
|
cellWidth?: number;
|
|
cellHeight?: number;
|
|
screenTop?: number;
|
|
}) {
|
|
const rows = options.rows ?? 24;
|
|
const cols = options.cols ?? 80;
|
|
const cellWidth = options.cellWidth ?? 8;
|
|
const cellHeight = options.cellHeight ?? 20;
|
|
const screenTop = options.screenTop ?? 100;
|
|
const absY = options.cursorY + options.baseY;
|
|
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
|
|
[absY]: {
|
|
isWrapped: options.wrapped,
|
|
text: options.wrapped ? "mmand that wraps" : "$ a-long-command",
|
|
},
|
|
};
|
|
if (options.wrapped) {
|
|
lines[absY - 1] = { text: "$ a-long-co" };
|
|
}
|
|
const screen = {
|
|
clientWidth: cols * cellWidth,
|
|
clientHeight: rows * cellHeight,
|
|
getBoundingClientRect: () => ({
|
|
left: 40,
|
|
top: screenTop,
|
|
right: 40 + cols * cellWidth,
|
|
bottom: screenTop + rows * cellHeight,
|
|
width: cols * cellWidth,
|
|
height: rows * cellHeight,
|
|
x: 40,
|
|
y: screenTop,
|
|
toJSON: () => ({}),
|
|
}),
|
|
};
|
|
const container = {
|
|
querySelector: (selector: string) => (selector === ".xterm-screen" ? screen : null),
|
|
} as unknown as HTMLElement;
|
|
const term = {
|
|
element: {
|
|
querySelector: () => null,
|
|
},
|
|
cols,
|
|
rows,
|
|
buffer: {
|
|
active: {
|
|
cursorX: options.wrapped ? 16 : 16,
|
|
cursorY: options.cursorY,
|
|
baseY: options.baseY,
|
|
viewportY: options.viewportY,
|
|
getLine: (y: number) => {
|
|
const row = lines[y];
|
|
if (!row) return undefined;
|
|
return {
|
|
isWrapped: row.isWrapped,
|
|
translateToString: () => row.text,
|
|
};
|
|
},
|
|
},
|
|
},
|
|
_core: {
|
|
_renderService: {
|
|
dimensions: {
|
|
css: {
|
|
cell: { width: cellWidth, height: cellHeight },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
return { term, container, cellHeight, screenTop };
|
|
}
|
|
|
|
test("resolveAutocompleteCommandStartRow follows wrap-induced viewport scroll", () => {
|
|
const before = makeWrappedCommandTerm({
|
|
cursorY: 23,
|
|
baseY: 100,
|
|
viewportY: 100,
|
|
wrapped: false,
|
|
});
|
|
assert.equal(resolveAutocompleteCommandStartRow(before.term as never), 23);
|
|
|
|
const after = makeWrappedCommandTerm({
|
|
cursorY: 23,
|
|
baseY: 101,
|
|
viewportY: 101,
|
|
wrapped: true,
|
|
});
|
|
assert.equal(resolveAutocompleteCommandStartRow(after.term as never), 22);
|
|
});
|
|
|
|
test("popup viewport follows the command start row after wrap-induced scroll", () => {
|
|
const before = makeWrappedCommandTerm({
|
|
cursorY: 23,
|
|
baseY: 100,
|
|
viewportY: 100,
|
|
wrapped: false,
|
|
});
|
|
const after = makeWrappedCommandTerm({
|
|
cursorY: 23,
|
|
baseY: 101,
|
|
viewportY: 101,
|
|
wrapped: true,
|
|
});
|
|
|
|
const beforeAnchor = resolveAutocompletePopupAnchorInViewport(
|
|
before.term as never,
|
|
before.container,
|
|
5,
|
|
16,
|
|
23,
|
|
);
|
|
const afterAnchor = resolveAutocompletePopupAnchorInViewport(
|
|
after.term as never,
|
|
after.container,
|
|
5,
|
|
16,
|
|
23,
|
|
);
|
|
|
|
assert.equal(beforeAnchor.expandUpward, true);
|
|
assert.equal(afterAnchor.expandUpward, true);
|
|
assert.equal(beforeAnchor.anchorTop, before.screenTop + 23 * before.cellHeight);
|
|
assert.equal(afterAnchor.anchorTop, after.screenTop + 22 * after.cellHeight);
|
|
assert.equal(afterAnchor.anchorTop, beforeAnchor.anchorTop - after.cellHeight);
|
|
|
|
const stored = nextAutocompletePopupAnchorViewport(
|
|
{
|
|
left: beforeAnchor.anchorLeft,
|
|
top: beforeAnchor.anchorTop,
|
|
bottom: beforeAnchor.anchorBottom,
|
|
},
|
|
beforeAnchor.expandUpward,
|
|
afterAnchor,
|
|
);
|
|
assert.ok(stored, "stored viewport should update when the command start row moves");
|
|
assert.equal(stored?.viewport.top, afterAnchor.anchorTop);
|
|
assert.equal(stored?.expandUpward, true);
|
|
});
|
|
|
|
test("resolveAutocompleteAnchorInViewport pins an upward popup to the wrapped command start", () => {
|
|
const { term, container, cellHeight, screenTop } = makeWrappedCommandTerm({
|
|
cursorY: 23,
|
|
baseY: 101,
|
|
viewportY: 101,
|
|
wrapped: true,
|
|
});
|
|
const atCursor = resolveAutocompleteAnchorInViewport(term as never, container, 5, 16, 23, 23);
|
|
const atStart = resolveAutocompleteAnchorInViewport(term as never, container, 5, 16, 23, 22);
|
|
assert.equal(atCursor.expandUpward, true);
|
|
assert.equal(atStart.expandUpward, true);
|
|
assert.equal(atCursor.anchorTop, screenTop + 23 * cellHeight);
|
|
assert.equal(atStart.anchorTop, screenTop + 22 * cellHeight);
|
|
assert.ok(
|
|
atStart.anchorTop < atCursor.anchorTop,
|
|
"command-start anchor must sit above the cursor so the popup cannot cover the first wrapped line",
|
|
);
|
|
});
|
|
|
|
test("nextAutocompletePopupAnchorViewport is a no-op when the stored viewport is already current", () => {
|
|
const anchor = {
|
|
anchorLeft: 10,
|
|
anchorTop: 20,
|
|
anchorBottom: 40,
|
|
expandUpward: true,
|
|
};
|
|
assert.equal(
|
|
nextAutocompletePopupAnchorViewport(
|
|
{ left: 10, top: 20, bottom: 40 },
|
|
true,
|
|
anchor,
|
|
),
|
|
null,
|
|
);
|
|
});
|