/** * Menus and context menus. Items are either ad-hoc (`{label, run}`) or bound * to registered actions (`{kind: "action", action: "editor.closeTab"}`), which * supplies title, shortcut and enablement from the action registry. */ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import clsx from "clsx"; import { create } from "zustand"; import { getAction, isActionEnabled, executeActionFrom, actionToggled } from "@/platform/actions"; import { useContextKeys } from "@/platform/context"; import { shortcutLabel } from "@/platform/keymap"; import { Icon, type IconName } from "./icons"; import { Popup, type PopupAnchor } from "./Popup"; import type { Placement } from "@floating-ui/react-dom"; import styles from "./Menu.module.css"; import { tx } from "@/platform/i18n"; export type MenuItem = | { kind: "separator" } | { kind: "header"; label: string } | { kind: "action"; action: string; arg?: unknown; label?: string; icon?: IconName | (string & {}); disabled?: boolean; } | { kind?: "item"; id?: string; label: string; /** User text (e.g. a past query): shown as is, never looked up in the catalog. */ verbatim?: boolean; icon?: IconName | (string & {}); shortcut?: string; disabled?: boolean; checked?: boolean; danger?: boolean; run?: () => void; /** The pointer or the keyboard made it the active item (not on opening the menu). */ onActive?: () => void; submenu?: MenuItem[]; }; interface Resolved { kind: "item" | "separator" | "header"; key: string; label: string; icon?: string; shortcut?: string; disabled?: boolean; checked?: boolean; danger?: boolean; run?: () => void; onActive?: () => void; submenu?: MenuItem[]; } export function resolveMenuItems(items: MenuItem[]): Resolved[] { const out: Resolved[] = []; items.forEach((item, i) => { if (item.kind === "separator") { if (out.length && out[out.length - 1]!.kind !== "separator") out.push({ kind: "separator", key: `sep${i}`, label: "" }); return; } if (item.kind === "header") { out.push({ kind: "header", key: `h${i}`, label: tx(item.label) }); return; } if (item.kind === "action") { const a = getAction(item.action); if (!a) return; out.push({ kind: "item", key: item.action, label: tx(item.label) ?? a.title, icon: item.icon ?? a.icon, shortcut: shortcutLabel(item.action), disabled: !!item.disabled || !isActionEnabled(a), checked: actionToggled(a), run: () => void executeActionFrom("menu", item.action, item.arg), }); return; } out.push({ ...item, label: item.verbatim ? item.label : tx(item.label), kind: "item", key: item.id ?? `${i}:${item.label}`, }); }); while (out.length && out[out.length - 1]!.kind === "separator") out.pop(); return out; } const selectable = (r: Resolved | undefined) => !!r && r.kind === "item" && !r.disabled; interface MenuListProps { items: MenuItem[]; /** Called after an item ran (closes the whole menu chain). */ onDone: () => void; /** Close just this level (submenus). */ onCloseLevel?: () => void; "aria-label"?: string; /** Focus the first item on mount. */ autoFocusFirst?: boolean; } export function MenuList({ items, onDone, onCloseLevel, autoFocusFirst = true, ...aria }: MenuListProps) { // Action enablement follows the context keys: opening the menu moves focus // (e.g. editorTextFocus → popupOpen) right after the first render. useContextKeys(); const resolved = resolveMenuItems(items); const [active, setActive] = useState(() => (autoFocusFirst ? resolved.findIndex(selectable) : -1)); const [openSub, setOpenSubIndex] = useState(null); const [subAnchor, setSubAnchor] = useState(null); const itemRefs = useRef>([]); const hoverTimer = useRef | undefined>(undefined); // Pointer movement highlights a row without focusing it. Focusing on every // enter drops the hover paint in the Windows webview, so the menu looks frozen. const pointerHover = useRef(false); // Row the pointer last entered: pointerenter and mouseenter both report it. const hovered = useRef(-1); const setOpenSub = (i: number | null) => { setSubAnchor(i === null ? null : (itemRefs.current[i] ?? null)); setOpenSubIndex(i); }; useLayoutEffect(() => { if (pointerHover.current) { pointerHover.current = false; return; } if (active >= 0 && openSub === null) itemRefs.current[active]?.focus({ preventScroll: true }); }, [active, openSub]); useEffect(() => () => clearTimeout(hoverTimer.current), []); const focusItem = (i: number) => { setActive(i); resolved[i]?.onActive?.(); }; const move = (delta: number, from = active) => { const n = resolved.length; if (!n) return; let i = from; for (let step = 0; step < n; step++) { i = (i + delta + n) % n; if (selectable(resolved[i])) { focusItem(i); return; } } }; const activate = (i: number) => { const item = resolved[i]; if (!selectable(item)) return; if (item!.submenu) { setActive(i); setOpenSub(i); return; } onDone(); item!.run?.(); }; const hover = (i: number) => { const item = resolved[i]; if (!item || item.disabled || item.kind !== "item") return; if (hovered.current !== i) { hovered.current = i; if (i !== active) { pointerHover.current = true; focusItem(i); } } clearTimeout(hoverTimer.current); hoverTimer.current = setTimeout(() => setOpenSub(item.submenu ? i : null), item.submenu ? 120 : 200); }; const onKeyDown = (e: ReactKeyboardEvent) => { switch (e.key) { case "ArrowDown": move(1); break; case "ArrowUp": move(-1, active < 0 ? 0 : active); break; case "Home": move(1, -1); break; case "End": move(-1, 0); break; case "ArrowRight": if (resolved[active]?.submenu) setOpenSub(active); else return; break; case "ArrowLeft": if (onCloseLevel) onCloseLevel(); else return; break; case "Enter": case " ": activate(active); break; default: if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { const ch = e.key.toLowerCase(); const n = resolved.length; for (let s = 1; s <= n; s++) { const i = (active + s + n) % n; const r = resolved[i]!; if (selectable(r) && r.label.toLowerCase().startsWith(ch)) { focusItem(i); break; } } break; } return; } e.preventDefault(); e.stopPropagation(); }; return (
{resolved.map((r, i) => { if (r.kind === "separator") return
; if (r.kind === "header") return (
{r.label}
); const hasSub = !!r.submenu; return ( {hasSub && openSub === i && subAnchor && ( setOpenSub(null)} className={styles.popup} > { setOpenSub(null); itemRefs.current[i]?.focus(); }} /> )} ); })}
); } export interface MenuProps { open: boolean; onClose: () => void; anchor: PopupAnchor; items: MenuItem[]; placement?: Placement; "aria-label"?: string; } /** A dropdown menu anchored to an element or point. */ export function Menu({ open, onClose, anchor, items, placement = "bottom-start", ...aria }: MenuProps) { return ( ); } // --------------------------------------------------------------------------- // Context menus interface ContextMenuState { menu: { x: number; y: number; items: MenuItem[]; id: number } | null; } export const useContextMenuStore = create(() => ({ menu: null })); let menuSeq = 0; export function showContextMenu( at: { clientX: number; clientY: number } | { x: number; y: number }, items: MenuItem[], ) { const x = "clientX" in at ? at.clientX : at.x; const y = "clientY" in at ? at.clientY : at.y; useContextMenuStore.setState({ menu: { x, y, items, id: ++menuSeq } }); } export function hideContextMenu() { useContextMenuStore.setState({ menu: null }); } /** Renders the active context menu; mount once (the workbench does). */ export function ContextMenuHost() { const menu = useContextMenuStore((s) => s.menu); if (!menu) return null; return ( ); }