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
614 lines
22 KiB
TypeScript
614 lines
22 KiB
TypeScript
/**
|
||
* Popup autocomplete menu for terminal.
|
||
* Renders a floating list of completion suggestions near the terminal cursor.
|
||
* Shows a detail tooltip for the selected/hovered item with full description.
|
||
* Colors are derived from the active terminal theme for visual consistency.
|
||
*/
|
||
|
||
import React, { useEffect, useLayoutEffect, useRef, useState, memo } from "react";
|
||
import { Folder, File, Link } from "lucide-react";
|
||
import type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||
import {
|
||
clampAutocompletePopupGeometry,
|
||
computeAutocompletePopupPlacement,
|
||
resolveAutocompleteClampViewport,
|
||
} from "./terminalAutocompleteLayout";
|
||
|
||
export interface AutocompleteThemeColors {
|
||
background: string;
|
||
foreground: string;
|
||
selection: string;
|
||
cursor: string;
|
||
}
|
||
|
||
export interface SubDirEntry {
|
||
name: string;
|
||
type: "file" | "directory" | "symlink";
|
||
}
|
||
|
||
export interface SubDirPanel {
|
||
entries: SubDirEntry[];
|
||
selectedIndex: number;
|
||
dirPath: string;
|
||
}
|
||
|
||
interface AutocompletePopupProps {
|
||
suggestions: CompletionSuggestion[];
|
||
selectedIndex: number;
|
||
/** Cursor anchor in viewport coordinates */
|
||
anchorViewport: { left: number; top: number; bottom: number };
|
||
visible: boolean;
|
||
expandUpward?: boolean;
|
||
themeColors?: AutocompleteThemeColors;
|
||
onSelect: (suggestion: CompletionSuggestion) => void;
|
||
maxHeight?: number;
|
||
subDirPanels?: SubDirPanel[];
|
||
subDirFocusLevel?: number;
|
||
/** Reference to the terminal container for calculating fixed position */
|
||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||
/** Ask the autocomplete controller to recompute cursor-relative popup position */
|
||
onRequestReposition?: () => void;
|
||
/** Offset from top of container to terminal content area (toolbar + search bar) */
|
||
searchBarOffset?: number;
|
||
/** Called when user clicks outside the popup to dismiss it */
|
||
onDismiss?: () => void;
|
||
}
|
||
|
||
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
|
||
history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" },
|
||
command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" },
|
||
subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" },
|
||
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
|
||
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
|
||
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
|
||
snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" },
|
||
plugin: { label: "P", fullLabel: "Plugin", fallbackColor: "#F472B6" },
|
||
};
|
||
|
||
/** Lucide icon components for file types in path suggestions */
|
||
const FILE_TYPE_CONFIG: Record<string, { Icon: React.FC<{ size?: number; color?: string }>; color: string }> = {
|
||
directory: { Icon: Folder, color: "#38BDF8" },
|
||
file: { Icon: File, color: "#94A3B8" },
|
||
symlink: { Icon: Link, color: "#A78BFA" },
|
||
};
|
||
|
||
const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => {
|
||
const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file;
|
||
return (
|
||
<span
|
||
style={{
|
||
width: "18px",
|
||
height: "18px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<cfg.Icon size={14} color={cfg.color} />
|
||
</span>
|
||
);
|
||
};
|
||
|
||
/** Chevron indicator for expandable directory items */
|
||
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
|
||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||
);
|
||
|
||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
boxSizing: "border-box",
|
||
height: "16px",
|
||
minWidth: "16px",
|
||
padding: "0 4px",
|
||
fontSize: "11px",
|
||
lineHeight: 1,
|
||
borderRadius: "4px",
|
||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||
flexShrink: 0,
|
||
fontFamily:
|
||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||
}}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
|
||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||
suggestions,
|
||
selectedIndex,
|
||
anchorViewport,
|
||
visible,
|
||
expandUpward = false,
|
||
themeColors,
|
||
onSelect,
|
||
maxHeight = 240,
|
||
subDirPanels = [],
|
||
subDirFocusLevel = -1,
|
||
containerRef,
|
||
onRequestReposition,
|
||
searchBarOffset: _searchBarOffset = 30,
|
||
onDismiss,
|
||
}) => {
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
const selectedRef = useRef<HTMLDivElement>(null);
|
||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (selectedRef.current && listRef.current) {
|
||
selectedRef.current.scrollIntoView({
|
||
block: "nearest",
|
||
behavior: "instant" as ScrollBehavior,
|
||
});
|
||
}
|
||
}, [selectedIndex]);
|
||
|
||
// Reset hover when suggestions change
|
||
useEffect(() => {
|
||
setHoveredIndex(-1);
|
||
}, [suggestions]);
|
||
|
||
useEffect(() => {
|
||
if (!visible || !onRequestReposition) return;
|
||
|
||
let frameId = 0;
|
||
const requestReposition = () => {
|
||
if (frameId) cancelAnimationFrame(frameId);
|
||
frameId = requestAnimationFrame(() => {
|
||
frameId = 0;
|
||
onRequestReposition();
|
||
});
|
||
};
|
||
|
||
const container = containerRef?.current;
|
||
const observer = container ? new ResizeObserver(requestReposition) : null;
|
||
observer?.observe(container);
|
||
window.addEventListener("resize", requestReposition);
|
||
|
||
return () => {
|
||
if (frameId) cancelAnimationFrame(frameId);
|
||
observer?.disconnect();
|
||
window.removeEventListener("resize", requestReposition);
|
||
};
|
||
}, [containerRef, onRequestReposition, visible]);
|
||
|
||
useEffect(() => {
|
||
if (!visible || !onRequestReposition || suggestions.length === 0) return;
|
||
|
||
let firstFrame = 0;
|
||
let secondFrame = 0;
|
||
firstFrame = requestAnimationFrame(() => {
|
||
onRequestReposition();
|
||
secondFrame = requestAnimationFrame(onRequestReposition);
|
||
});
|
||
|
||
return () => {
|
||
if (firstFrame) cancelAnimationFrame(firstFrame);
|
||
if (secondFrame) cancelAnimationFrame(secondFrame);
|
||
};
|
||
}, [onRequestReposition, subDirPanels.length, suggestions, visible]);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!visible || suggestions.length === 0) {
|
||
setMeasuredSize((current) => (current === null ? current : null));
|
||
return;
|
||
}
|
||
|
||
let frameId = 0;
|
||
const measure = () => {
|
||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||
setMeasuredSize((current) => {
|
||
if (
|
||
current &&
|
||
Math.abs(current.width - rect.width) < 0.5 &&
|
||
Math.abs(current.height - rect.height) < 0.5
|
||
) {
|
||
return current;
|
||
}
|
||
return { width: rect.width, height: rect.height };
|
||
});
|
||
};
|
||
|
||
measure();
|
||
const wrapper = wrapperRef.current;
|
||
const observer = wrapper ? new ResizeObserver(measure) : null;
|
||
observer?.observe(wrapper);
|
||
frameId = requestAnimationFrame(measure);
|
||
|
||
return () => {
|
||
if (frameId) cancelAnimationFrame(frameId);
|
||
observer?.disconnect();
|
||
};
|
||
}, [hoveredIndex, selectedIndex, subDirPanels, suggestions, visible]);
|
||
|
||
// Dismiss popup when clicking outside
|
||
useEffect(() => {
|
||
if (!visible || !onDismiss) return;
|
||
const handlePointerDown = (e: PointerEvent) => {
|
||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||
onDismiss();
|
||
}
|
||
};
|
||
document.addEventListener("pointerdown", handlePointerDown);
|
||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||
}, [visible, onDismiss]);
|
||
|
||
if (!visible || suggestions.length === 0) return null;
|
||
|
||
const bg = themeColors?.background ?? "#1e1e2e";
|
||
const fg = themeColors?.foreground ?? "#cdd6f4";
|
||
// Accent comes from the active terminal theme's cursor/selection colors,
|
||
// which already track the user's accent setting (custom accent rewrites them
|
||
// in applyCustomAccentToTerminalTheme). Falling back to selection, then a
|
||
// neutral fg-mix, keeps older/partial theme payloads working. This is what
|
||
// makes the popup's highlight follow the accent instead of a hardcoded blue.
|
||
const accent = themeColors?.cursor || themeColors?.selection || fg;
|
||
const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`;
|
||
const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`;
|
||
const selectedBg = `color-mix(in srgb, ${accent} 26%, ${bg} 74%)`;
|
||
const selectedBorderAccent = `color-mix(in srgb, ${accent} 60%, ${bg} 40%)`;
|
||
const hoverBg = `color-mix(in srgb, ${accent} 12%, ${bg} 88%)`;
|
||
const textColor = fg;
|
||
const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`;
|
||
|
||
// Determine which item to show the detail tooltip for
|
||
const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex;
|
||
const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null;
|
||
const showDetail = detailItem?.description && detailItem.description.length > 0;
|
||
|
||
// Whether ANY item in the current set can open the detail tooltip (non-path
|
||
// row with a description). Placement reserves space from this set-level flag
|
||
// rather than the hovered item, so moving the mouse between rows can't change
|
||
// totalWidth/height and shift the popup out from under the pointer.
|
||
const setMayShowDetailPanel = suggestions.some(
|
||
(s) => s.source !== "path" && Boolean(s.description && s.description.length > 0),
|
||
);
|
||
|
||
const fixedLeft = anchorViewport.left;
|
||
const fixedLineTop = anchorViewport.top;
|
||
const fixedLineBottom = anchorViewport.bottom;
|
||
|
||
const viewportPadding = 8;
|
||
const anchorGap = 8;
|
||
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
|
||
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.
|
||
const estimatedDetailHeight = setMayShowDetailPanel ? 96 : 0;
|
||
const desiredContentHeight = Math.max(estimatedPopupHeight, estimatedDetailHeight);
|
||
|
||
// Total horizontal extent so the WHOLE assembly is clamped inside the
|
||
// viewport — not just the main list. Mirrors the rendered maxWidths:
|
||
// main list (400) + each cascading sub-dir panel (240) + the detail
|
||
// tooltip (280), separated by the flex gap (4). Without this, expanding a
|
||
// directory near the right edge pushed the sub-panels off-screen (#1202).
|
||
const FLEX_GAP = 4;
|
||
const MAIN_LIST_MAX_WIDTH = 400;
|
||
const SUBDIR_PANEL_MAX_WIDTH = 240;
|
||
const DETAIL_PANEL_MAX_WIDTH = 280;
|
||
const totalWidth =
|
||
MAIN_LIST_MAX_WIDTH +
|
||
subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH) +
|
||
(setMayShowDetailPanel ? FLEX_GAP + DETAIL_PANEL_MAX_WIDTH : 0);
|
||
const clampWidth =
|
||
MAIN_LIST_MAX_WIDTH +
|
||
subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH);
|
||
|
||
const placement = computeAutocompletePopupPlacement({
|
||
anchorTop: fixedLineTop,
|
||
anchorBottom: fixedLineBottom,
|
||
anchorLeft: fixedLeft,
|
||
viewportWidth: clampViewport.width,
|
||
viewportHeight: clampViewport.height,
|
||
clampViewport,
|
||
desiredHeight: desiredContentHeight,
|
||
totalWidth,
|
||
clampWidth,
|
||
maxHeight,
|
||
anchorGap,
|
||
viewportPadding,
|
||
expandUpwardHint: expandUpward,
|
||
forceExpandUpward: expandUpward,
|
||
});
|
||
const renderUpward = placement.renderUpward;
|
||
const effectiveMaxHeight = placement.maxHeight;
|
||
const anchoredTop = placement.top;
|
||
const clampedLeft = placement.left;
|
||
const finalGeometry = measuredSize
|
||
? clampAutocompletePopupGeometry({
|
||
left: clampedLeft,
|
||
top: anchoredTop,
|
||
width: measuredSize.width,
|
||
height: measuredSize.height,
|
||
clampViewport,
|
||
viewportPadding,
|
||
})
|
||
: { left: clampedLeft, top: anchoredTop };
|
||
|
||
const sharedBoxStyle = {
|
||
// border-box so each panel's maxWidth is its true outer width (padding +
|
||
// border included). The horizontal clamp's totalWidth sums these maxWidths,
|
||
// so this keeps the off-screen math exact even for the padded detail panel.
|
||
boxSizing: "border-box" as const,
|
||
backgroundColor: popupBg,
|
||
border: `1px solid ${popupBorder}`,
|
||
borderRadius: "6px",
|
||
boxShadow: renderUpward
|
||
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
|
||
: "0 2px 6px rgba(0, 0, 0, 0.15)",
|
||
fontFamily: "inherit",
|
||
fontSize: "13px",
|
||
color: textColor,
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={wrapperRef}
|
||
style={{
|
||
position: "fixed",
|
||
left: `${finalGeometry.left}px`,
|
||
top: `${finalGeometry.top}px`,
|
||
zIndex: 10000,
|
||
display: "flex",
|
||
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();
|
||
}}
|
||
>
|
||
{/* Main suggestion list */}
|
||
<div
|
||
ref={listRef}
|
||
className="xterm-autocomplete-popup"
|
||
style={{
|
||
...sharedBoxStyle,
|
||
maxHeight: `${effectiveMaxHeight}px`,
|
||
minWidth: "180px",
|
||
maxWidth: "400px",
|
||
overflowY: "auto",
|
||
overflowX: "hidden",
|
||
padding: "4px 0",
|
||
userSelect: "none",
|
||
}}
|
||
>
|
||
{suggestions.map((suggestion, index) => {
|
||
const isSelected = index === selectedIndex;
|
||
const isHovered = index === hoveredIndex;
|
||
const sourceInfo = SOURCE_LABELS[suggestion.source];
|
||
|
||
return (
|
||
<div
|
||
key={`${suggestion.text}-${index}`}
|
||
ref={isSelected ? selectedRef : undefined}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
padding: "5px 10px",
|
||
cursor: "pointer",
|
||
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
|
||
// Accent rail on the active row so the highlight reads as the
|
||
// theme accent. Inset shadow avoids shifting row layout.
|
||
boxShadow: isSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
|
||
gap: "8px",
|
||
lineHeight: "1.4",
|
||
}}
|
||
onMouseEnter={() => setHoveredIndex(index)}
|
||
onMouseLeave={() => setHoveredIndex(-1)}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onSelect(suggestion);
|
||
}}
|
||
>
|
||
{/* Source / file type indicator */}
|
||
{suggestion.source === "path" && suggestion.fileType ? (
|
||
<FileTypeIcon fileType={suggestion.fileType} />
|
||
) : (
|
||
<span
|
||
role="img"
|
||
aria-label={sourceInfo.fullLabel}
|
||
title={sourceInfo.fullLabel}
|
||
style={{
|
||
width: "18px",
|
||
height: "18px",
|
||
borderRadius: "3px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
fontSize: "10px",
|
||
fontWeight: 600,
|
||
color: sourceInfo.fallbackColor,
|
||
backgroundColor: `${sourceInfo.fallbackColor}15`,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{sourceInfo.label}
|
||
</span>
|
||
)}
|
||
|
||
{/* Command text */}
|
||
<span
|
||
style={{
|
||
flex: 1,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
color: textColor,
|
||
fontWeight: isSelected ? 500 : 400,
|
||
}}
|
||
>
|
||
{suggestion.displayText}
|
||
</span>
|
||
|
||
{/* Inline description (truncated). Snippets show only their label
|
||
in the row — the full command lives in the detail preview. */}
|
||
{suggestion.source !== "snippet" && suggestion.description && (
|
||
<span
|
||
style={{
|
||
fontSize: "11px",
|
||
color: dimTextColor,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
maxWidth: "160px",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{suggestion.description}
|
||
</span>
|
||
)}
|
||
|
||
{/* Frequency badge for history */}
|
||
{suggestion.frequency && suggestion.frequency > 1 && (
|
||
<span
|
||
style={{
|
||
fontSize: "10px",
|
||
color: dimTextColor,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
×{suggestion.frequency}
|
||
</span>
|
||
)}
|
||
|
||
{/* Expand indicator for directories */}
|
||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||
)}
|
||
|
||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||
{isSelected && (
|
||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||
)}
|
||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Cascading sub-directory panels */}
|
||
{subDirPanels.map((panel, level) => (
|
||
<div
|
||
key={panel.dirPath}
|
||
style={{
|
||
...sharedBoxStyle,
|
||
maxHeight: `${effectiveMaxHeight}px`,
|
||
minWidth: "150px",
|
||
maxWidth: "240px",
|
||
overflowY: "auto",
|
||
overflowX: "hidden",
|
||
padding: "4px 0",
|
||
userSelect: "none",
|
||
alignSelf: "flex-start",
|
||
}}
|
||
>
|
||
{panel.entries.map((entry, idx) => {
|
||
const isFocused = level === subDirFocusLevel;
|
||
const isSubSelected = isFocused && idx === panel.selectedIndex;
|
||
return (
|
||
<div
|
||
key={entry.name}
|
||
ref={isSubSelected ? (el) => { el?.scrollIntoView({ block: "nearest" }); } : undefined}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
padding: "4px 10px",
|
||
cursor: "pointer",
|
||
backgroundColor: isSubSelected ? selectedBg
|
||
: (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg
|
||
: "transparent",
|
||
boxShadow: isSubSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
|
||
gap: "8px",
|
||
lineHeight: "1.4",
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}}
|
||
>
|
||
<FileTypeIcon fileType={entry.type} />
|
||
<span style={{
|
||
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap", color: textColor,
|
||
}}>
|
||
{entry.name}{entry.type === "directory" ? "/" : ""}
|
||
</span>
|
||
{entry.type === "directory" && (
|
||
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
|
||
{/* Detail tooltip panel — shows full description for non-path items */}
|
||
{showDetail && detailItem && detailItem.source !== "path" && (
|
||
<div
|
||
style={{
|
||
...sharedBoxStyle,
|
||
padding: "10px 12px",
|
||
maxWidth: "280px",
|
||
minWidth: "160px",
|
||
// 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",
|
||
}}
|
||
>
|
||
<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>
|
||
);
|
||
};
|
||
|
||
export default memo(AutocompletePopup);
|