import React, { useEffect, useMemo, useRef, useState } from "react" import { getIconForFilePath, getIconUrlByName, getIconForDirectoryPath } from "vscode-material-icons" import { Trans } from "react-i18next" import { t } from "i18next" import { Settings } from "lucide-react" import type { ModeConfig, Command } from "@roo-code/types" import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions, SearchResult, } from "@src/utils/context-mentions" import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric" import { vscode } from "@src/utils/vscode" import { buildDocLink } from "@/utils/docLinks" interface ContextMenuProps { onSelect: (type: ContextMenuOptionType, value?: string) => void searchQuery: string inputValue: string onMouseDown: () => void selectedIndex: number setSelectedIndex: (index: number) => void selectedType: ContextMenuOptionType | null queryItems: ContextMenuQueryItem[] modes?: ModeConfig[] loading?: boolean dynamicSearchResults?: SearchResult[] commands?: Command[] } const ContextMenu: React.FC = ({ onSelect, searchQuery, onMouseDown, selectedIndex, setSelectedIndex, selectedType, queryItems, modes, dynamicSearchResults = [], commands = [], }) => { const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") const menuRef = useRef(null) const filteredOptions = useMemo(() => { return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands) }, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands]) useEffect(() => { if (menuRef.current) { const selectedElement = menuRef.current.children[selectedIndex] as HTMLElement if (selectedElement) { const menuRect = menuRef.current.getBoundingClientRect() const selectedRect = selectedElement.getBoundingClientRect() if (selectedRect.bottom > menuRect.bottom) { menuRef.current.scrollTop += selectedRect.bottom - menuRect.bottom } else if (selectedRect.top < menuRect.top) { menuRef.current.scrollTop -= menuRect.top - selectedRect.top } } } }, [selectedIndex]) // get the icons base uri on mount useEffect(() => { const w = window as any setMaterialIconsBaseUri(w.MATERIAL_ICONS_BASE_URI) }, []) const renderOptionContent = (option: ContextMenuQueryItem) => { switch (option.type) { case ContextMenuOptionType.SectionHeader: return ( {option.label} ) case ContextMenuOptionType.Mode: return (
{option.slashCommand}
{option.description && ( {option.description} )}
) case ContextMenuOptionType.Command: return (
{option.slashCommand} {option.argumentHint && ( {option.argumentHint} )}
{option.description && ( {option.description} )}
) case ContextMenuOptionType.Problems: return {t("chat:contextMenu.problems")} case ContextMenuOptionType.Terminal: return {t("chat:contextMenu.terminal")} case ContextMenuOptionType.URL: return {t("chat:contextMenu.url")} case ContextMenuOptionType.NoResults: return {t("chat:contextMenu.noResults")} case ContextMenuOptionType.Git: if (option.value) { return (
{option.label} {option.description}
) } else { return Git Commits } case ContextMenuOptionType.File: case ContextMenuOptionType.OpenedFile: case ContextMenuOptionType.Folder: if (option.value) { // remove trailing slash const path = removeLeadingNonAlphanumeric(option.value || "").replace(/\/$/, "") const pathList = path.split("/") const filename = pathList.at(-1) const folderPath = pathList.slice(0, -1).join("/") return (
{filename} {folderPath}
) } else { return Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"} } } } const getIconForOption = (option: ContextMenuQueryItem): string => { switch (option.type) { case ContextMenuOptionType.Mode: return "symbol-misc" case ContextMenuOptionType.Command: return "play" case ContextMenuOptionType.OpenedFile: return "window" case ContextMenuOptionType.File: return "file" case ContextMenuOptionType.Folder: return "folder" case ContextMenuOptionType.Problems: return "warning" case ContextMenuOptionType.Terminal: return "terminal" case ContextMenuOptionType.URL: return "link" case ContextMenuOptionType.Git: return "git-commit" case ContextMenuOptionType.NoResults: return "info" default: return "file" } } const getMaterialIconForOption = (option: ContextMenuQueryItem): string => { // only take the last part of the path to handle both file and folder icons // since material-icons have specific folder icons, we use them if available const name = option.value?.split("/").filter(Boolean).at(-1) ?? "" const iconName = option.type === ContextMenuOptionType.Folder ? getIconForDirectoryPath(name) : getIconForFilePath(name) return getIconUrlByName(iconName, materialIconsBaseUri) } const isOptionSelectable = (option: ContextMenuQueryItem): boolean => { return ( option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.URL && option.type !== ContextMenuOptionType.SectionHeader ) } const handleSettingsClick = (e: React.MouseEvent) => { // Prevent any default behavior e.preventDefault() // Switch to settings tab and navigate to slash commands section vscode.postMessage({ type: "switchTab", tab: "settings", values: { section: "slashCommands" }, }) } return (
{/* Settings button for slash commands */} {searchQuery === "/" && (
{searchQuery.length === 1 && (

Slash Commands

{t("common:docsLink.label")} ), }} />

)}
)} {filteredOptions && filteredOptions.length > 0 ? ( filteredOptions.map((option, index) => (
isOptionSelectable(option) && onSelect(option.type, option.value)} style={{ padding: option.type === ContextMenuOptionType.SectionHeader ? "16px 8px 4px 8px" : "4px 8px", cursor: isOptionSelectable(option) ? "pointer" : "default", color: "var(--vscode-dropdown-foreground)", display: "flex", alignItems: "center", justifyContent: "space-between", position: "relative", ...(option.type === ContextMenuOptionType.SectionHeader ? { borderBottom: "1px solid var(--vscode-editorGroup-border)", marginBottom: "2px", } : {}), ...(index === selectedIndex && isOptionSelectable(option) ? { backgroundColor: "var(--vscode-list-activeSelectionBackground)", color: "var(--vscode-list-activeSelectionForeground)", } : {}), }} onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder || option.type === ContextMenuOptionType.OpenedFile) && ( Mode )} {option.type !== ContextMenuOptionType.Mode && option.type !== ContextMenuOptionType.Command && option.type !== ContextMenuOptionType.File && option.type !== ContextMenuOptionType.Folder && option.type !== ContextMenuOptionType.OpenedFile && option.type !== ContextMenuOptionType.SectionHeader && getIconForOption(option) && ( )} {renderOptionContent(option)}
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder || option.type === ContextMenuOptionType.Git) && !option.value && ( )}
)) ) : (
{t("chat:contextMenu.noResults")}
)}
) } export default ContextMenu