Compare commits

...
2 changed files with 303 additions and 42 deletions

No files matched your search

@@ -0,0 +1,206 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
/**
* Regression test for #3426: hovering autocomplete rows must not change the
* popup's rendered geometry. The detail tooltip used to mount/unmount and
* resize per hovered row, which fed the wrapper's measured size into the
* position clamp and moved the popup under a stationary pointer — flipping
* the hovered row and self-oscillating (violent mousemove jitter).
*/
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("Element", window.Element);
installGlobal("Node", window.Node);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
try {
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const AutocompletePopup = (await import("./autocomplete/AutocompletePopup.tsx")).default;
type CompletionSuggestion = import("./autocomplete/completionEngine.ts").CompletionSuggestion;
const suggestions: CompletionSuggestion[] = [
{
text: "git status",
displayText: "git status",
description: "Show the working tree status",
source: "command",
score: 10,
},
{
text: "src",
displayText: "src/",
source: "path",
fileType: "directory",
score: 9,
},
{
text: "git commit",
displayText: "git commit",
description: "Record changes to the repository",
source: "command",
score: 8,
},
];
const containerRef = { current: null } as React.RefObject<HTMLDivElement | null>;
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
await act(async () => {
root.render(
<AutocompletePopup
suggestions={suggestions}
selectedIndex={-1}
anchorViewport={{ left: 100, top: 300, bottom: 316 }}
visible
themeColors={{ background: "#1e1e2e", foreground: "#cdd6f4", selection: "#444", cursor: "#f5e0dc" }}
onSelect={() => {}}
containerRef={containerRef}
/>,
);
});
const findWrapper = (): HTMLElement => {
const el = Array.from(rootNode.querySelectorAll<HTMLElement>("div"))
.find((candidate) => candidate.style.zIndex === "10000");
assert.ok(el, "popup wrapper should be mounted");
return el;
};
const findDetailPanel = (): HTMLElement | null =>
Array.from(rootNode.querySelectorAll<HTMLElement>("div"))
.find((el) => el.style.width === "280px") ?? null;
const wrapper = findWrapper();
const initialLeft = wrapper.style.left;
const initialTop = wrapper.style.top;
assert.ok(initialLeft && initialTop, "popup should be fixed-positioned");
// Nothing hovered/selected: the detail panel is mounted but hidden so its
// box is already reserved (matches the placement pass's reservation).
const hiddenPanel = findDetailPanel();
assert.ok(hiddenPanel, "detail panel should stay mounted for the whole set");
assert.equal(hiddenPanel.style.visibility, "hidden");
const fixedHeight = hiddenPanel.style.height;
assert.ok(fixedHeight && fixedHeight !== "0px", "detail panel should reserve a fixed height");
const hoverRow = async (rowIndex: number) => {
const label = Array.from(rootNode.querySelectorAll<HTMLElement>("span"))
.find((el) => el.textContent === suggestions[rowIndex].displayText);
assert.ok(label, `row for ${suggestions[rowIndex].displayText} should be rendered`);
const row = label.closest<HTMLElement>("div[style*='cursor']");
assert.ok(row, "row container should be a hoverable div");
await act(async () => {
row.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
});
};
// Hover the describable row: detail becomes visible, geometry unchanged.
await hoverRow(0);
assert.equal(wrapper.style.left, initialLeft, "hover must not shift popup left");
assert.equal(wrapper.style.top, initialTop, "hover must not change popup top");
const shownPanel = findDetailPanel();
assert.ok(shownPanel);
assert.equal(shownPanel.style.visibility, "visible");
assert.equal(shownPanel.style.height, fixedHeight, "detail box must be hover-independent");
assert.ok(shownPanel.textContent?.includes("Show the working tree status"));
// Hover the path row (no detail): panel hides via visibility, stays
// mounted with the same fixed box, and the popup does not move.
await hoverRow(1);
assert.equal(wrapper.style.left, initialLeft, "hovering a path row must not move the popup");
assert.equal(wrapper.style.top, initialTop, "hovering a path row must not shift the popup");
const panelAfterPathHover = findDetailPanel();
assert.ok(panelAfterPathHover, "detail panel must remain mounted while hovering path rows");
assert.equal(panelAfterPathHover.style.visibility, "hidden");
assert.equal(panelAfterPathHover.style.height, fixedHeight);
assert.equal(panelAfterPathHover.style.width, "280px");
// Hovering another describable row is equally geometry-stable.
await hoverRow(2);
assert.equal(wrapper.style.left, initialLeft);
assert.equal(wrapper.style.top, initialTop);
// A hidden detail reserve must not intercept clicks through the wrapper.
assert.equal(wrapper.style.pointerEvents, "none");
await hoverRow(1);
assert.equal(findDetailPanel()?.style.pointerEvents, "none");
await hoverRow(0);
assert.equal(findDetailPanel()?.style.pointerEvents, "auto");
// Exercise pane sizes down to the supported split minimum. JSDOM cannot
// perform flex layout, so check the constraints that let Chromium shrink
// both panels without tying their size to the hovered description.
for (const width of [400, 240, 120]) {
const container = window.document.createElement("div");
container.getBoundingClientRect = () => ({
left: 0, top: 0, right: width, bottom: 600,
width, height: 600, x: 0, y: 0, toJSON: () => ({}),
});
await act(async () => {
root.render(
<AutocompletePopup
suggestions={suggestions}
selectedIndex={-1}
anchorViewport={{ left: width - 30, top: 500, bottom: 516 }}
visible
onSelect={() => {}}
containerRef={{ current: container }}
/>,
);
});
assert.equal(findWrapper().style.maxWidth, `${width - 16}px`);
const list = rootNode.querySelector<HTMLElement>(".xterm-autocomplete-popup");
assert.ok(list);
assert.ok(parseFloat(list.style.minWidth) <= (width - 16) / 2);
for (const index of [0, 1, 2]) {
await hoverRow(index);
const detail = findDetailPanel();
assert.ok(detail);
assert.equal(detail.style.minWidth, "0px");
assert.equal(detail.style.flexShrink, "1");
}
}
await act(async () => {
root.unmount();
});
} finally {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
@@ -229,7 +229,11 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
if (frameId) cancelAnimationFrame(frameId);
observer?.disconnect();
};
}, [hoveredIndex, selectedIndex, subDirPanels, suggestions, visible]);
// hoveredIndex intentionally not a dependency: the detail panel below has
// a fixed box (width/height), so hovering cannot change the wrapper's
// size — re-measuring per hover would only feed the resize back into the
// position clamp and jitter the popup under the pointer (#3426).
}, [selectedIndex, subDirPanels, suggestions, visible]);
// Dismiss popup when clicking outside
useEffect(() => {
@@ -273,6 +277,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
const setMayShowDetailPanel = suggestions.some(
(s) => s.source !== "path" && Boolean(s.description && s.description.length > 0),
);
// The tooltip is only visible for the hovered/selected non-path row, but it
// is MOUNTED for the whole set with a fixed box (width = the reserved
// DETAIL_PANEL_MAX_WIDTH, height = the reserved estimatedDetailHeight) and
// toggled with visibility instead of mounting/unmounting. A hover-driven
// size change would flow through the measured-size clamp below and move the
// popup under a stationary pointer, which flips the hovered row and
// self-oscillates — the violent mousemove jitter from #3426.
const detailVisible = Boolean(
showDetail && detailItem && detailItem.source !== "path",
);
const fixedLeft = anchorViewport.left;
const fixedLineTop = anchorViewport.top;
@@ -281,6 +295,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
const viewportPadding = 8;
const anchorGap = 8;
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
const availableWidth = Math.max(0, clampViewport.width - viewportPadding * 2);
const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8);
// Reserve the detail height for the whole set (not the hovered row) so the
// chosen direction/height stays stable while hovering.
@@ -322,6 +337,10 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
});
const renderUpward = placement.renderUpward;
const effectiveMaxHeight = placement.maxHeight;
// Fixed tooltip height: matches the 96px the placement pass reserved
// (estimatedDetailHeight), capped by the space actually available so the
// mounted-but-hidden panel can never push the wrapper past the clamp.
const detailPanelHeight = Math.max(0, Math.min(96, effectiveMaxHeight));
const anchoredTop = placement.top;
const clampedLeft = placement.left;
const finalGeometry = measuredSize
@@ -360,13 +379,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
top: `${finalGeometry.top}px`,
zIndex: 10000,
display: "flex",
maxWidth: `${availableWidth}px`,
alignItems: renderUpward ? "flex-end" : "flex-start",
gap: "4px",
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
// Hit-test transparent: the wrapper's box spans the whole assembly,
// including the mounted-but-hidden detail reserve, so leaving it
// auto would make the transparent area around a short list a dead
// zone (clicks there preventDefault + stopPropagation and never
// reach the terminal or the outside-dismiss handler). Children
// re-enable pointer events individually. (#3427 review)
pointerEvents: "none",
}}
>
{/* Main suggestion list */}
@@ -376,12 +398,19 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "180px",
minWidth: `${Math.min(180, setMayShowDetailPanel ? availableWidth / 2 : availableWidth)}px`,
maxWidth: "400px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
pointerEvents: "auto",
}}
// Keep the terminal focused when clicking the list's padding or
// scrollbar (row clicks handle this themselves).
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{suggestions.map((suggestion, index) => {
@@ -517,6 +546,13 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
padding: "4px 0",
userSelect: "none",
alignSelf: "flex-start",
pointerEvents: "auto",
}}
// Keep the terminal focused when clicking the panel's padding or
// scrollbar (row clicks handle this themselves).
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{panel.entries.map((entry, idx) => {
@@ -559,51 +595,70 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
</div>
))}
{/* Detail tooltip panel — shows full description for non-path items */}
{showDetail && detailItem && detailItem.source !== "path" && (
{/* Detail tooltip panel — shows full description for non-path items.
Mounted for the whole set with a fixed, hover-independent box and
toggled via visibility so hovering rows can never resize (and thus
re-clamp the position of) the popup (#3426). */}
{setMayShowDetailPanel && (
<div
style={{
...sharedBoxStyle,
padding: "10px 12px",
maxWidth: "280px",
minWidth: "160px",
width: "280px",
minWidth: 0,
height: `${detailPanelHeight}px`,
// The width remains independent of content/hover, but can shrink
// with the assembly inside a narrow terminal pane.
flexShrink: 1,
// Bound the tooltip too: a long multi-line snippet description must
// scroll, not push the panel past the viewport edge (#1202).
maxHeight: `${effectiveMaxHeight}px`,
overflowY: "auto",
alignSelf: renderUpward ? "flex-end" : "flex-start",
visibility: detailVisible ? "visible" : "hidden",
// visibility:hidden already skips hit testing, but keep the
// hidden reserve explicitly non-interactive so the mounted
// panel can never swallow clicks meant for the terminal.
pointerEvents: detailVisible ? "auto" : "none",
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
<span style={{
fontSize: "10px",
color: SOURCE_LABELS[detailItem.source].fallbackColor,
padding: "1px 5px",
borderRadius: "3px",
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
}}>
{SOURCE_LABELS[detailItem.source].fullLabel}
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.source === "snippet" ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--terminal-font, monospace)",
fontSize: "11px",
lineHeight: 1.4,
}}
>
{detailItem.description}
</pre>
) : (
detailItem.description
)}
</div>
{detailVisible && detailItem && (
<>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
<span style={{
fontSize: "10px",
color: SOURCE_LABELS[detailItem.source].fallbackColor,
padding: "1px 5px",
borderRadius: "3px",
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
}}>
{SOURCE_LABELS[detailItem.source].fullLabel}
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.source === "snippet" ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--terminal-font, monospace)",
fontSize: "11px",
lineHeight: 1.4,
}}
>
{detailItem.description}
</pre>
) : (
detailItem.description
)}
</div>
</>
)}
</div>
)}
</div>