openhands / src /components /features /command-menu /command-menu.tsx
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3201ca6 verified
Raw
History Blame Contribute Delete
14.4 kB
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,
);
}