| import React from "react"; |
| import { createPortal } from "react-dom"; |
| import { Search, X } from "lucide-react"; |
| import { useTranslation } from "react-i18next"; |
| import { I18nKey } from "#/i18n/declaration"; |
| import { useNavigation } from "#/context/navigation-context"; |
| import { useCommandMenuStore } from "#/stores/command-menu-store"; |
| import { useSidebarStore } from "#/stores/sidebar-store"; |
| import { cn } from "#/utils/utils"; |
| import { |
| COMMAND_MENU_GROUP_LABELS, |
| COMMAND_MENU_GROUP_ORDER, |
| type CommandMenuItemDefinition, |
| commandMenuItemCopy, |
| createCommandMenuItems, |
| } from "./command-menu-items"; |
|
|
| const COMMAND_MENU_SEARCH_INPUT_ID = "command-menu-search"; |
| const COMMAND_MENU_LISTBOX_ID = "command-menu-results"; |
| const COMMAND_MENU_OPTION_ID_PREFIX = "command-menu-option"; |
| const COMMAND_MENU_TEST_ID = "command-menu"; |
| const COMMAND_MENU_SHORTCUT_KEY = "k"; |
| const COMMAND_MENU_ARROW_DOWN_KEY = "ArrowDown"; |
| const COMMAND_MENU_ARROW_UP_KEY = "ArrowUp"; |
| const COMMAND_MENU_ENTER_KEY = "Enter"; |
| const COMMAND_MENU_ESCAPE_KEY = "Escape"; |
| const EMPTY_QUERY = ""; |
| const EMPTY_RESULTS_ACTIVE_INDEX = -1; |
|
|
| function getOptionId(item: CommandMenuItemDefinition) { |
| return `${COMMAND_MENU_OPTION_ID_PREFIX}-${item.id}`; |
| } |
|
|
| function matchesQuery({ |
| item, |
| query, |
| translate, |
| }: { |
| item: CommandMenuItemDefinition; |
| query: string; |
| translate: (key: I18nKey) => string; |
| }) { |
| const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); |
|
|
| if (terms.length === 0) { |
| return true; |
| } |
|
|
| const searchableText = [ |
| commandMenuItemCopy(item.title, item.titleKey, translate), |
| commandMenuItemCopy(item.description, item.descriptionKey, translate), |
| commandMenuItemCopy(item.keywords, item.keywordsKey, translate), |
| ] |
| .join(" ") |
| .toLocaleLowerCase(); |
|
|
| return terms.every((term) => searchableText.includes(term)); |
| } |
|
|
| export function CommandMenu() { |
| const { t } = useTranslation("openhands"); |
| const { navigate } = useNavigation(); |
| const isOpen = useCommandMenuStore((state) => state.isOpen); |
| const open = useCommandMenuStore((state) => state.open); |
| const close = useCommandMenuStore((state) => state.close); |
| const [query, setQuery] = React.useState(EMPTY_QUERY); |
| const [activeIndex, setActiveIndex] = React.useState(0); |
| const inputRef = React.useRef<HTMLInputElement>(null); |
| const optionRefs = React.useRef(new Map<string, HTMLElement>()); |
|
|
| React.useEffect(() => { |
| const onKeyDown = (event: KeyboardEvent) => { |
| if ( |
| (event.metaKey || event.ctrlKey) && |
| event.key.toLocaleLowerCase() === COMMAND_MENU_SHORTCUT_KEY |
| ) { |
| event.preventDefault(); |
| open(); |
| } |
| }; |
|
|
| window.addEventListener("keydown", onKeyDown); |
| return () => window.removeEventListener("keydown", onKeyDown); |
| }, [open]); |
|
|
| React.useEffect(() => { |
| if (!isOpen) { |
| setQuery(EMPTY_QUERY); |
| setActiveIndex(0); |
| optionRefs.current.clear(); |
| return undefined; |
| } |
|
|
| const frame = requestAnimationFrame(() => inputRef.current?.focus()); |
| return () => cancelAnimationFrame(frame); |
| }, [isOpen]); |
|
|
| const items = React.useMemo( |
| () => |
| createCommandMenuItems({ |
| toggleSidebar: () => useSidebarStore.getState().toggleCollapsed(), |
| }), |
| [], |
| ); |
|
|
| const filteredItems = React.useMemo( |
| () => |
| items.filter((item) => |
| matchesQuery({ item, query, translate: (key) => t(key) }), |
| ), |
| [items, query, t], |
| ); |
|
|
| React.useEffect(() => { |
| setActiveIndex((currentIndex) => { |
| if (filteredItems.length === 0) { |
| return EMPTY_RESULTS_ACTIVE_INDEX; |
| } |
| return Math.min(Math.max(currentIndex, 0), filteredItems.length - 1); |
| }); |
| }, [filteredItems.length]); |
|
|
| React.useEffect(() => { |
| const activeItem = filteredItems[activeIndex]; |
| if (!activeItem) { |
| return; |
| } |
|
|
| const activeNode = optionRefs.current.get(activeItem.id); |
| if (typeof activeNode?.scrollIntoView === "function") { |
| activeNode.scrollIntoView({ |
| block: "nearest", |
| }); |
| } |
| }, [activeIndex, filteredItems]); |
|
|
| const runItem = React.useCallback( |
| (item: CommandMenuItemDefinition | undefined) => { |
| if (!item) { |
| return; |
| } |
|
|
| close(); |
| if (item.to) { |
| navigate(item.to); |
| return; |
| } |
| item.perform?.(); |
| }, |
| [close, navigate], |
| ); |
|
|
| const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
| if (event.key === COMMAND_MENU_ARROW_DOWN_KEY) { |
| event.preventDefault(); |
| setActiveIndex((index) => |
| filteredItems.length === 0 ? index : (index + 1) % filteredItems.length, |
| ); |
| return; |
| } |
|
|
| if (event.key === COMMAND_MENU_ARROW_UP_KEY) { |
| event.preventDefault(); |
| setActiveIndex((index) => |
| filteredItems.length === 0 |
| ? index |
| : (index - 1 + filteredItems.length) % filteredItems.length, |
| ); |
| return; |
| } |
|
|
| if (event.key === COMMAND_MENU_ENTER_KEY) { |
| event.preventDefault(); |
| runItem(filteredItems[activeIndex]); |
| return; |
| } |
|
|
| if (event.key === COMMAND_MENU_ESCAPE_KEY) { |
| event.preventDefault(); |
| close(); |
| } |
| }; |
|
|
| if (!isOpen || typeof document === "undefined") { |
| return null; |
| } |
|
|
| const activeItem = filteredItems[activeIndex]; |
|
|
| return createPortal( |
| <div |
| className="fixed inset-0 z-[70] flex items-start justify-center px-3 pt-[10vh] sm:px-6" |
| data-testid={COMMAND_MENU_TEST_ID} |
| role="dialog" |
| aria-modal="true" |
| aria-label={t(I18nKey.COMMAND_MENU$ARIA_LABEL)} |
| > |
| <button |
| type="button" |
| className="absolute inset-0 cursor-default bg-black/65 backdrop-blur-[2px]" |
| aria-label={t(I18nKey.COMMAND_MENU$CLOSE_LABEL)} |
| onClick={close} |
| /> |
| <div |
| className={cn( |
| "relative flex max-h-[min(720px,78vh)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl", |
| "border border-[var(--oh-border)] bg-[var(--oh-surface)]", |
| "shadow-[0_24px_90px_rgba(0,0,0,0.52),0_0_0_1px_rgba(255,255,255,0.03)_inset]", |
| )} |
| > |
| <div className="relative flex items-center gap-3 border-b border-[var(--oh-border)] px-4 py-3"> |
| <Search className="size-5 shrink-0 text-[var(--oh-text-dim)]" /> |
| <input |
| ref={inputRef} |
| id={COMMAND_MENU_SEARCH_INPUT_ID} |
| className="h-11 min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-[var(--oh-text-dim)]" |
| placeholder={t(I18nKey.COMMAND_MENU$PLACEHOLDER)} |
| aria-label={t(I18nKey.COMMAND_MENU$SEARCH_LABEL)} |
| role="combobox" |
| aria-expanded="true" |
| aria-controls={COMMAND_MENU_LISTBOX_ID} |
| aria-activedescendant={ |
| activeItem ? getOptionId(activeItem) : undefined |
| } |
| value={query} |
| onChange={(event) => setQuery(event.target.value)} |
| onKeyDown={handleInputKeyDown} |
| /> |
| {query ? ( |
| <button |
| type="button" |
| className="inline-flex size-8 items-center justify-center rounded-lg text-[var(--oh-muted)] hover:bg-[var(--oh-surface-raised)] hover:text-white" |
| aria-label={t(I18nKey.COMMAND_MENU$CLEAR_SEARCH_LABEL)} |
| onClick={() => { |
| setQuery(EMPTY_QUERY); |
| inputRef.current?.focus(); |
| }} |
| > |
| <X className="size-4" /> |
| </button> |
| ) : null} |
| <kbd className="hidden rounded-md border border-[var(--oh-border)] bg-black/25 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--oh-text-dim)] sm:inline-flex"> |
| {t(I18nKey.COMMAND_MENU$SHORTCUT)} |
| </kbd> |
| </div> |
| |
| <div |
| id={COMMAND_MENU_LISTBOX_ID} |
| role="listbox" |
| className="relative min-h-0 flex-1 overflow-y-auto px-2 py-2 custom-scrollbar" |
| > |
| {filteredItems.length === 0 ? ( |
| <div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center"> |
| <div className="flex size-11 items-center justify-center rounded-2xl border border-dashed border-[var(--oh-border)] text-[var(--oh-text-dim)]"> |
| <Search className="size-5" /> |
| </div> |
| <p className="text-sm font-medium text-white"> |
| {t(I18nKey.COMMAND_MENU$NO_RESULTS_TITLE)} |
| </p> |
| <p className="max-w-sm text-xs leading-5 text-[var(--oh-muted)]"> |
| {t(I18nKey.COMMAND_MENU$NO_RESULTS_DESCRIPTION)} |
| </p> |
| </div> |
| ) : ( |
| COMMAND_MENU_GROUP_ORDER.map((groupId) => { |
| const groupItems = filteredItems.filter( |
| (item) => item.group === groupId, |
| ); |
| |
| if (groupItems.length === 0) { |
| return null; |
| } |
| |
| return ( |
| <section key={groupId} className="py-1"> |
| <div className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--oh-text-dim)]"> |
| {t(COMMAND_MENU_GROUP_LABELS[groupId])} |
| </div> |
| <div className="space-y-1"> |
| {groupItems.map((item) => { |
| const itemIndex = filteredItems.indexOf(item); |
| const isActive = itemIndex === activeIndex; |
| const to = item.to; |
| |
| const assignRef = (node: HTMLElement | null) => { |
| if (node) { |
| optionRefs.current.set(item.id, node); |
| } else { |
| optionRefs.current.delete(item.id); |
| } |
| }; |
| |
| const optionClassName = cn( |
| "group flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150", |
| isActive |
| ? "bg-white/[0.09] text-white shadow-[0_0_0_1px_rgba(255,255,255,0.08)_inset]" |
| : "text-[var(--oh-muted)] hover:bg-white/[0.05] hover:text-white", |
| ); |
| |
| const content = ( |
| <> |
| <span |
| className={cn( |
| "flex size-9 shrink-0 items-center justify-center rounded-lg border transition-colors duration-150", |
| isActive |
| ? "border-[var(--oh-accent)] bg-[var(--oh-accent)]/15 text-white" |
| : "border-[var(--oh-border)] bg-black/15 text-[var(--oh-text-dim)] group-hover:text-white", |
| )} |
| aria-hidden="true" |
| > |
| {item.icon} |
| </span> |
| <span className="min-w-0 flex-1"> |
| <span className="block truncate text-sm font-medium text-current"> |
| {commandMenuItemCopy( |
| item.title, |
| item.titleKey, |
| t, |
| )} |
| </span> |
| <span className="mt-0.5 block truncate text-xs text-[var(--oh-text-dim)]"> |
| {commandMenuItemCopy( |
| item.description, |
| item.descriptionKey, |
| t, |
| )} |
| </span> |
| </span> |
| <span className="hidden shrink-0 rounded-md border border-[var(--oh-border)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--oh-text-dim)] sm:inline-flex"> |
| {to |
| ? t(I18nKey.COMMAND_MENU$GO_HINT) |
| : t(I18nKey.COMMAND_MENU$RUN_HINT)} |
| </span> |
| </> |
| ); |
| |
| if (to) { |
| return ( |
| <a |
| key={item.id} |
| ref={assignRef} |
| id={getOptionId(item)} |
| href={to} |
| role="option" |
| aria-selected={isActive} |
| onMouseEnter={() => setActiveIndex(itemIndex)} |
| onClick={(event) => { |
| if ( |
| event.metaKey || |
| event.ctrlKey || |
| event.shiftKey || |
| event.altKey |
| ) { |
| return; |
| } |
| event.preventDefault(); |
| runItem(item); |
| }} |
| className={optionClassName} |
| > |
| {content} |
| </a> |
| ); |
| } |
| |
| return ( |
| <button |
| key={item.id} |
| ref={assignRef} |
| id={getOptionId(item)} |
| type="button" |
| role="option" |
| aria-selected={isActive} |
| onMouseEnter={() => setActiveIndex(itemIndex)} |
| onClick={() => runItem(item)} |
| className={optionClassName} |
| > |
| {content} |
| </button> |
| ); |
| })} |
| </div> |
| </section> |
| ); |
| }) |
| )} |
| </div> |
| |
| <div className="border-t border-[var(--oh-border)] px-4 py-2.5 text-[11px] text-[var(--oh-text-dim)]"> |
| {t(I18nKey.COMMAND_MENU$FOOTER_HINT)} |
| </div> |
| </div> |
| </div>, |
| document.body, |
| ); |
| } |
|
|