mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-24 00:51:34 +00:00
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
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<ContextMenuProps> = ({
|
|
onSelect,
|
|
searchQuery,
|
|
onMouseDown,
|
|
selectedIndex,
|
|
setSelectedIndex,
|
|
selectedType,
|
|
queryItems,
|
|
modes,
|
|
dynamicSearchResults = [],
|
|
commands = [],
|
|
}) => {
|
|
const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
|
|
const menuRef = useRef<HTMLDivElement>(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 (
|
|
<span
|
|
style={{
|
|
fontWeight: "bold",
|
|
fontSize: "0.85em",
|
|
opacity: 0.8,
|
|
}}>
|
|
{option.label}
|
|
</span>
|
|
)
|
|
case ContextMenuOptionType.Mode:
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
|
<div style={{ lineHeight: "1.2" }}>
|
|
<span>{option.slashCommand}</span>
|
|
</div>
|
|
{option.description && (
|
|
<span
|
|
style={{
|
|
opacity: 0.5,
|
|
fontSize: "0.9em",
|
|
lineHeight: "1.2",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}>
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
case ContextMenuOptionType.Command:
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
|
<div style={{ lineHeight: "1.2", display: "flex", alignItems: "center", gap: "6px" }}>
|
|
<span>{option.slashCommand}</span>
|
|
{option.argumentHint && (
|
|
<span
|
|
style={{
|
|
opacity: 0.5,
|
|
fontSize: "0.9em",
|
|
lineHeight: "1.2",
|
|
}}>
|
|
{option.argumentHint}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{option.description && (
|
|
<span
|
|
style={{
|
|
opacity: 0.5,
|
|
fontSize: "0.9em",
|
|
lineHeight: "1.2",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}>
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
case ContextMenuOptionType.Problems:
|
|
return <span>{t("chat:contextMenu.problems")}</span>
|
|
case ContextMenuOptionType.Terminal:
|
|
return <span>{t("chat:contextMenu.terminal")}</span>
|
|
case ContextMenuOptionType.URL:
|
|
return <span>{t("chat:contextMenu.url")}</span>
|
|
case ContextMenuOptionType.NoResults:
|
|
return <span>{t("chat:contextMenu.noResults")}</span>
|
|
case ContextMenuOptionType.Git:
|
|
if (option.value) {
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
|
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.85em",
|
|
opacity: 0.7,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
lineHeight: "1.2",
|
|
}}>
|
|
{option.description}
|
|
</span>
|
|
</div>
|
|
)
|
|
} else {
|
|
return <span>Git Commits</span>
|
|
}
|
|
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 (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
gap: "0.5em",
|
|
whiteSpace: "nowrap",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
textAlign: "left",
|
|
}}>
|
|
<span>{filename}</span>
|
|
<span
|
|
style={{
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
direction: "rtl",
|
|
textAlign: "right",
|
|
flex: 1,
|
|
opacity: 0.75,
|
|
fontSize: "0.75em",
|
|
}}>
|
|
{folderPath}
|
|
</span>
|
|
</div>
|
|
)
|
|
} else {
|
|
return <span>Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"}</span>
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
bottom: "calc(100% - 10px)",
|
|
left: 15,
|
|
right: 15,
|
|
overflowX: "hidden",
|
|
}}
|
|
onMouseDown={onMouseDown}>
|
|
<div
|
|
ref={menuRef}
|
|
style={{
|
|
backgroundColor: "var(--vscode-dropdown-background)",
|
|
border: "1px solid var(--vscode-editorGroup-border)",
|
|
borderRadius: "3px",
|
|
boxShadow: "0 4px 10px rgba(0, 0, 0, 0.25)",
|
|
zIndex: 1000,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
maxHeight: "300px",
|
|
overflowY: "auto",
|
|
overflowX: "hidden",
|
|
}}>
|
|
{/* Settings button for slash commands */}
|
|
{searchQuery === "/" && (
|
|
<div className="p-2 flex items-start gap-4 justify-between">
|
|
{searchQuery.length === 1 && (
|
|
<div className="text-sm">
|
|
<p className="font-bold text-base text-vscode-foreground mt-1 mb-0.5">Slash Commands</p>
|
|
<p className="text-xs mt-0.5 -mb-1">
|
|
<Trans
|
|
i18nKey="settings:slashCommands.description"
|
|
components={{
|
|
DocsLink: (
|
|
<a
|
|
href={buildDocLink(
|
|
"features/slash-commands",
|
|
"slash_commands_settings",
|
|
)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-vscode-textLink-foreground hover:underline">
|
|
{t("common:docsLink.label")}
|
|
</a>
|
|
),
|
|
}}
|
|
/>
|
|
</p>
|
|
</div>
|
|
)}
|
|
<button
|
|
className="mt-1 cursor-pointer"
|
|
onClick={handleSettingsClick}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.opacity = "1"
|
|
e.currentTarget.style.backgroundColor = "var(--vscode-list-hoverBackground)"
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.opacity = "0.7"
|
|
e.currentTarget.style.backgroundColor = "transparent"
|
|
}}
|
|
title={t("chat:slashCommands.manageCommands")}>
|
|
<Settings size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{filteredOptions && filteredOptions.length > 0 ? (
|
|
filteredOptions.map((option, index) => (
|
|
<div
|
|
key={`${option.type}-${option.value || index}`}
|
|
onClick={() => 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)}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
minWidth: 0,
|
|
overflow: "hidden",
|
|
paddingTop: 0,
|
|
position: "relative",
|
|
}}>
|
|
{(option.type === ContextMenuOptionType.File ||
|
|
option.type === ContextMenuOptionType.Folder ||
|
|
option.type === ContextMenuOptionType.OpenedFile) && (
|
|
<img
|
|
src={getMaterialIconForOption(option)}
|
|
alt="Mode"
|
|
style={{
|
|
marginRight: "6px",
|
|
flexShrink: 0,
|
|
width: "16px",
|
|
height: "16px",
|
|
}}
|
|
/>
|
|
)}
|
|
{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) && (
|
|
<i
|
|
className={`codicon codicon-${getIconForOption(option)}`}
|
|
style={{
|
|
marginRight: "6px",
|
|
flexShrink: 0,
|
|
fontSize: "14px",
|
|
marginTop: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
{renderOptionContent(option)}
|
|
</div>
|
|
{(option.type === ContextMenuOptionType.File ||
|
|
option.type === ContextMenuOptionType.Folder ||
|
|
option.type === ContextMenuOptionType.Git) &&
|
|
!option.value && (
|
|
<i
|
|
className="codicon codicon-chevron-right"
|
|
style={{ fontSize: "10px", flexShrink: 0, marginLeft: 8 }}
|
|
/>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div
|
|
style={{
|
|
padding: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "var(--vscode-foreground)",
|
|
opacity: 0.7,
|
|
}}>
|
|
<span>{t("chat:contextMenu.noResults")}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ContextMenu
|