|
| 1 | +import React, { useState, useMemo } from "react" |
| 2 | +import { Check, ChevronDown, Info, X } from "lucide-react" |
| 3 | +import { cn } from "../../lib/utils" |
| 4 | +import { useTranslation, Trans } from "react-i18next" |
| 5 | +import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" |
| 6 | +import { StandardTooltip } from "../ui/standard-tooltip" |
| 7 | + |
| 8 | +interface CommandPattern { |
| 9 | + pattern: string |
| 10 | + description?: string |
| 11 | +} |
| 12 | + |
| 13 | +interface CommandPatternSelectorProps { |
| 14 | + command: string |
| 15 | + patterns: CommandPattern[] |
| 16 | + allowedCommands: string[] |
| 17 | + deniedCommands: string[] |
| 18 | + onAllowPatternChange: (pattern: string) => void |
| 19 | + onDenyPatternChange: (pattern: string) => void |
| 20 | +} |
| 21 | + |
| 22 | +export const CommandPatternSelector: React.FC<CommandPatternSelectorProps> = ({ |
| 23 | + command, |
| 24 | + patterns, |
| 25 | + allowedCommands, |
| 26 | + deniedCommands, |
| 27 | + onAllowPatternChange, |
| 28 | + onDenyPatternChange, |
| 29 | +}) => { |
| 30 | + const { t } = useTranslation() |
| 31 | + const [isExpanded, setIsExpanded] = useState(false) |
| 32 | + const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({}) |
| 33 | + |
| 34 | + // Create a combined list with full command first, then patterns |
| 35 | + const allPatterns = useMemo(() => { |
| 36 | + const fullCommandPattern: CommandPattern = { pattern: command } |
| 37 | + |
| 38 | + // Create a set to track unique patterns we've already seen |
| 39 | + const seenPatterns = new Set<string>() |
| 40 | + seenPatterns.add(command) // Add the full command first |
| 41 | + |
| 42 | + // Filter out any patterns that are duplicates or are the same as the full command |
| 43 | + const uniquePatterns = patterns.filter((p) => { |
| 44 | + if (seenPatterns.has(p.pattern)) { |
| 45 | + return false |
| 46 | + } |
| 47 | + seenPatterns.add(p.pattern) |
| 48 | + return true |
| 49 | + }) |
| 50 | + |
| 51 | + return [fullCommandPattern, ...uniquePatterns] |
| 52 | + }, [command, patterns]) |
| 53 | + |
| 54 | + const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => { |
| 55 | + if (allowedCommands.includes(pattern)) return "allowed" |
| 56 | + if (deniedCommands.includes(pattern)) return "denied" |
| 57 | + return "none" |
| 58 | + } |
| 59 | + |
| 60 | + const getEditState = (pattern: string) => { |
| 61 | + return editingStates[pattern] || { isEditing: false, value: pattern } |
| 62 | + } |
| 63 | + |
| 64 | + const setEditState = (pattern: string, isEditing: boolean, value?: string) => { |
| 65 | + setEditingStates((prev) => ({ |
| 66 | + ...prev, |
| 67 | + [pattern]: { isEditing, value: value ?? pattern }, |
| 68 | + })) |
| 69 | + } |
| 70 | + |
| 71 | + return ( |
| 72 | + <div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30"> |
| 73 | + <button |
| 74 | + onClick={() => setIsExpanded(!isExpanded)} |
| 75 | + className="w-full px-3 py-2 flex items-center justify-between hover:bg-vscode-list-hoverBackground transition-colors"> |
| 76 | + <div className="flex items-center gap-2"> |
| 77 | + <ChevronDown |
| 78 | + className={cn("size-4 transition-transform", { |
| 79 | + "-rotate-90": !isExpanded, |
| 80 | + })} |
| 81 | + /> |
| 82 | + <span className="text-sm font-medium">{t("chat:commandExecution.manageCommands")}</span> |
| 83 | + <StandardTooltip |
| 84 | + content={ |
| 85 | + <div className="max-w-xs"> |
| 86 | + <Trans |
| 87 | + i18nKey="chat:commandExecution.commandManagementDescription" |
| 88 | + components={{ |
| 89 | + settingsLink: ( |
| 90 | + <VSCodeLink |
| 91 | + href="command:workbench.action.openSettings?%5B%22roo-code%22%5D" |
| 92 | + className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground" |
| 93 | + /> |
| 94 | + ), |
| 95 | + }} |
| 96 | + /> |
| 97 | + </div> |
| 98 | + }> |
| 99 | + <Info className="size-3.5 text-vscode-descriptionForeground" /> |
| 100 | + </StandardTooltip> |
| 101 | + </div> |
| 102 | + </button> |
| 103 | + |
| 104 | + {isExpanded && ( |
| 105 | + <div className="px-3 pb-3 space-y-2"> |
| 106 | + {allPatterns.map((item) => { |
| 107 | + const editState = getEditState(item.pattern) |
| 108 | + const status = getPatternStatus(editState.value) |
| 109 | + |
| 110 | + return ( |
| 111 | + <div key={item.pattern} className="ml-5 flex items-center gap-2"> |
| 112 | + <div className="flex-1"> |
| 113 | + {editState.isEditing ? ( |
| 114 | + <input |
| 115 | + type="text" |
| 116 | + value={editState.value} |
| 117 | + onChange={(e) => setEditState(item.pattern, true, e.target.value)} |
| 118 | + onBlur={() => setEditState(item.pattern, false)} |
| 119 | + onKeyDown={(e) => { |
| 120 | + if (e.key === "Enter") { |
| 121 | + setEditState(item.pattern, false) |
| 122 | + } |
| 123 | + if (e.key === "Escape") { |
| 124 | + setEditState(item.pattern, false, item.pattern) |
| 125 | + } |
| 126 | + }} |
| 127 | + className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1.5 w-full focus:outline-0 focus:ring-1 focus:ring-vscode-focusBorder" |
| 128 | + placeholder={item.pattern} |
| 129 | + autoFocus |
| 130 | + /> |
| 131 | + ) : ( |
| 132 | + <div |
| 133 | + onClick={() => setEditState(item.pattern, true)} |
| 134 | + className="font-mono text-xs text-vscode-foreground cursor-pointer hover:bg-vscode-list-hoverBackground px-2 py-1.5 rounded transition-colors border border-transparent" |
| 135 | + title="Click to edit pattern"> |
| 136 | + <span>{editState.value}</span> |
| 137 | + {item.description && ( |
| 138 | + <span className="text-vscode-descriptionForeground ml-2"> |
| 139 | + - {item.description} |
| 140 | + </span> |
| 141 | + )} |
| 142 | + </div> |
| 143 | + )} |
| 144 | + </div> |
| 145 | + <div className="flex items-center gap-1"> |
| 146 | + <button |
| 147 | + className={cn("p-1 rounded transition-all", { |
| 148 | + "bg-green-500/20 text-green-500 hover:bg-green-500/30": |
| 149 | + status === "allowed", |
| 150 | + "text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10": |
| 151 | + status !== "allowed", |
| 152 | + })} |
| 153 | + onClick={() => onAllowPatternChange(editState.value)} |
| 154 | + aria-label={t( |
| 155 | + status === "allowed" |
| 156 | + ? "chat:commandExecution.removeFromAllowed" |
| 157 | + : "chat:commandExecution.addToAllowed", |
| 158 | + )}> |
| 159 | + <Check className="size-3.5" /> |
| 160 | + </button> |
| 161 | + <button |
| 162 | + className={cn("p-1 rounded transition-all", { |
| 163 | + "bg-red-500/20 text-red-500 hover:bg-red-500/30": status === "denied", |
| 164 | + "text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10": |
| 165 | + status !== "denied", |
| 166 | + })} |
| 167 | + onClick={() => onDenyPatternChange(editState.value)} |
| 168 | + aria-label={t( |
| 169 | + status === "denied" |
| 170 | + ? "chat:commandExecution.removeFromDenied" |
| 171 | + : "chat:commandExecution.addToDenied", |
| 172 | + )}> |
| 173 | + <X className="size-3.5" /> |
| 174 | + </button> |
| 175 | + </div> |
| 176 | + </div> |
| 177 | + ) |
| 178 | + })} |
| 179 | + </div> |
| 180 | + )} |
| 181 | + </div> |
| 182 | + ) |
| 183 | +} |
0 commit comments