mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944ffc5de5 | ||
|
|
bdcc83a399 | ||
|
|
19a62f52a6 |
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>
|
||||
|
||||
Reference in new issue
Block a user