mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
feat: restore command pattern extraction with editable UI
- Re-added command-parser.ts and its tests - Modified CommandPatternSelector to show both full command and extracted patterns - Each pattern (including full command) is now individually editable with click-to-edit functionality - Updated tests to match new component interface - Maintains the improved UI with hover effects and focus rings
This commit is contained in:
parent
20f99a2523
commit
851617c0ff
5 changed files with 505 additions and 176 deletions
|
|
@ -13,6 +13,12 @@ import { cn } from "@src/lib/utils"
|
|||
import { Button } from "@src/components/ui"
|
||||
import CodeBlock from "../common/CodeBlock"
|
||||
import { CommandPatternSelector } from "./CommandPatternSelector"
|
||||
import { extractPatternsFromCommand } from "../../utils/command-parser"
|
||||
|
||||
interface CommandPattern {
|
||||
pattern: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface CommandExecutionProps {
|
||||
executionId: string
|
||||
|
|
@ -66,11 +72,19 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
|
|||
// streaming output (this is the case for running commands).
|
||||
const output = streamingOutput || parsedOutput
|
||||
|
||||
// Handle command changes
|
||||
const handleAllowCommandChange = (cmd: string) => {
|
||||
const isAllowed = allowedCommands.includes(cmd)
|
||||
const newAllowed = isAllowed ? allowedCommands.filter((c) => c !== cmd) : [...allowedCommands, cmd]
|
||||
const newDenied = deniedCommands.filter((c) => c !== cmd)
|
||||
// Extract command patterns from the actual command that was executed
|
||||
const commandPatterns = useMemo<CommandPattern[]>(() => {
|
||||
const extractedPatterns = extractPatternsFromCommand(command)
|
||||
return extractedPatterns.map((pattern) => ({
|
||||
pattern,
|
||||
}))
|
||||
}, [command])
|
||||
|
||||
// Handle pattern changes
|
||||
const handleAllowPatternChange = (pattern: string) => {
|
||||
const isAllowed = allowedCommands.includes(pattern)
|
||||
const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern]
|
||||
const newDenied = deniedCommands.filter((p) => p !== pattern)
|
||||
|
||||
setAllowedCommands(newAllowed)
|
||||
setDeniedCommands(newDenied)
|
||||
|
|
@ -78,10 +92,10 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
|
|||
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
|
||||
}
|
||||
|
||||
const handleDenyCommandChange = (cmd: string) => {
|
||||
const isDenied = deniedCommands.includes(cmd)
|
||||
const newDenied = isDenied ? deniedCommands.filter((c) => c !== cmd) : [...deniedCommands, cmd]
|
||||
const newAllowed = allowedCommands.filter((c) => c !== cmd)
|
||||
const handleDenyPatternChange = (pattern: string) => {
|
||||
const isDenied = deniedCommands.includes(pattern)
|
||||
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
|
||||
const newAllowed = allowedCommands.filter((p) => p !== pattern)
|
||||
|
||||
setAllowedCommands(newAllowed)
|
||||
setDeniedCommands(newDenied)
|
||||
|
|
@ -181,10 +195,11 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
|
|||
{command && (
|
||||
<CommandPatternSelector
|
||||
command={command}
|
||||
patterns={commandPatterns}
|
||||
allowedCommands={allowedCommands}
|
||||
deniedCommands={deniedCommands}
|
||||
onAllowCommandChange={handleAllowCommandChange}
|
||||
onDenyCommandChange={handleDenyCommandChange}
|
||||
onAllowPatternChange={handleAllowPatternChange}
|
||||
onDenyPatternChange={handleDenyPatternChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,146 +1,179 @@
|
|||
import React, { useState } from "react"
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Check, ChevronDown, Info, X } from "lucide-react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { useTranslation, Trans } from "react-i18next"
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { StandardTooltip } from "../ui/standard-tooltip"
|
||||
|
||||
interface CommandPattern {
|
||||
pattern: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface CommandPatternSelectorProps {
|
||||
command: string
|
||||
patterns: CommandPattern[]
|
||||
allowedCommands: string[]
|
||||
deniedCommands: string[]
|
||||
onAllowCommandChange: (command: string) => void
|
||||
onDenyCommandChange: (command: string) => void
|
||||
onAllowPatternChange: (pattern: string) => void
|
||||
onDenyPatternChange: (pattern: string) => void
|
||||
}
|
||||
|
||||
export const CommandPatternSelector: React.FC<CommandPatternSelectorProps> = ({
|
||||
command,
|
||||
patterns,
|
||||
allowedCommands,
|
||||
deniedCommands,
|
||||
onAllowCommandChange,
|
||||
onDenyCommandChange,
|
||||
onAllowPatternChange,
|
||||
onDenyPatternChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [editedCommand, setEditedCommand] = useState(command)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
|
||||
|
||||
const getCommandStatus = (cmd: string): "allowed" | "denied" | "none" => {
|
||||
if (allowedCommands.includes(cmd)) return "allowed"
|
||||
if (deniedCommands.includes(cmd)) return "denied"
|
||||
// Create a combined list with full command first, then patterns
|
||||
const allPatterns = useMemo(() => {
|
||||
const fullCommandPattern = { pattern: command, description: "Full command" }
|
||||
return [fullCommandPattern, ...patterns]
|
||||
}, [command, patterns])
|
||||
|
||||
const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => {
|
||||
if (allowedCommands.includes(pattern)) return "allowed"
|
||||
if (deniedCommands.includes(pattern)) return "denied"
|
||||
return "none"
|
||||
}
|
||||
|
||||
const currentStatus = getCommandStatus(editedCommand)
|
||||
const getEditState = (pattern: string) => {
|
||||
return editingStates[pattern] || { isEditing: false, value: pattern }
|
||||
}
|
||||
|
||||
const setEditState = (pattern: string, isEditing: boolean, value?: string) => {
|
||||
setEditingStates((prev) => ({
|
||||
...prev,
|
||||
[pattern]: { isEditing, value: value ?? pattern },
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-vscode-descriptionForeground hover:text-vscode-foreground hover:bg-vscode-list-hoverBackground transition-all"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t(
|
||||
isExpanded ? "chat:commandExecution.collapseManagement" : "chat:commandExecution.expandManagement",
|
||||
)}>
|
||||
<ChevronDown
|
||||
className={cn("size-3 transition-transform duration-200", {
|
||||
"rotate-0": isExpanded,
|
||||
"-rotate-90": !isExpanded,
|
||||
})}
|
||||
/>
|
||||
<span className="font-medium">{t("chat:commandExecution.manageCommands")}</span>
|
||||
<StandardTooltip
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="chat:commandExecution.commandManagementDescription"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<VSCodeLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.postMessage(
|
||||
{
|
||||
type: "action",
|
||||
action: "settingsButtonClicked",
|
||||
values: { section: "autoApprove" },
|
||||
},
|
||||
"*",
|
||||
)
|
||||
className="w-full px-3 py-2 flex items-center justify-between hover:bg-vscode-list-hoverBackground transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform", {
|
||||
"-rotate-90": !isExpanded,
|
||||
})}
|
||||
/>
|
||||
<span className="text-sm font-medium">{t("chat:commandExecution.commandPermissions")}</span>
|
||||
<StandardTooltip
|
||||
content={
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<p>{t("chat:commandExecution.permissionsTooltip")}</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="chat:commandExecution.learnMore"
|
||||
components={{
|
||||
link: (
|
||||
<VSCodeLink
|
||||
href="https://docs.roo-code.com/features/command-permissions"
|
||||
className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
className="inline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<Info className="size-3 ml-1" />
|
||||
</StandardTooltip>
|
||||
</p>
|
||||
</div>
|
||||
}>
|
||||
<Info className="size-3.5 text-vscode-descriptionForeground" />
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground">
|
||||
<span>
|
||||
{allowedCommands.length} {t("chat:commandExecution.allowed")}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{deniedCommands.length} {t("chat:commandExecution.denied")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
<div className="ml-5 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedCommand}
|
||||
onChange={(e) => setEditedCommand(e.target.value)}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsEditing(false)
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditedCommand(command)
|
||||
setIsEditing(false)
|
||||
}
|
||||
}}
|
||||
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"
|
||||
placeholder={command}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setIsEditing(true)}
|
||||
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"
|
||||
title="Click to edit command">
|
||||
{editedCommand}
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{allPatterns.map((item) => {
|
||||
const editState = getEditState(item.pattern)
|
||||
const status = getPatternStatus(editState.value)
|
||||
|
||||
return (
|
||||
<div key={item.pattern} className="ml-5 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{editState.isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editState.value}
|
||||
onChange={(e) => setEditState(item.pattern, true, e.target.value)}
|
||||
onBlur={() => setEditState(item.pattern, false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setEditState(item.pattern, false)
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditState(item.pattern, false, item.pattern)
|
||||
}
|
||||
}}
|
||||
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"
|
||||
placeholder={item.pattern}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setEditState(item.pattern, true)}
|
||||
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"
|
||||
title="Click to edit pattern">
|
||||
<span>{editState.value}</span>
|
||||
{item.description && (
|
||||
<span className="text-vscode-descriptionForeground ml-2">
|
||||
- {item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={cn("p-1 rounded transition-all", {
|
||||
"bg-green-500/20 text-green-500 hover:bg-green-500/30": currentStatus === "allowed",
|
||||
"text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10":
|
||||
currentStatus !== "allowed",
|
||||
})}
|
||||
onClick={() => onAllowCommandChange(editedCommand)}
|
||||
aria-label={t(
|
||||
currentStatus === "allowed"
|
||||
? "chat:commandExecution.removeFromAllowed"
|
||||
: "chat:commandExecution.addToAllowed",
|
||||
)}>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={cn("p-1 rounded transition-all", {
|
||||
"bg-red-500/20 text-red-500 hover:bg-red-500/30": currentStatus === "denied",
|
||||
"text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10":
|
||||
currentStatus !== "denied",
|
||||
})}
|
||||
onClick={() => onDenyCommandChange(editedCommand)}
|
||||
aria-label={t(
|
||||
currentStatus === "denied"
|
||||
? "chat:commandExecution.removeFromDenied"
|
||||
: "chat:commandExecution.addToDenied",
|
||||
)}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={cn("p-1 rounded transition-all", {
|
||||
"bg-green-500/20 text-green-500 hover:bg-green-500/30":
|
||||
status === "allowed",
|
||||
"text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10":
|
||||
status !== "allowed",
|
||||
})}
|
||||
onClick={() => onAllowPatternChange(editState.value)}
|
||||
aria-label={t(
|
||||
status === "allowed"
|
||||
? "chat:commandExecution.removeFromAllowed"
|
||||
: "chat:commandExecution.addToAllowed",
|
||||
)}>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={cn("p-1 rounded transition-all", {
|
||||
"bg-red-500/20 text-red-500 hover:bg-red-500/30": status === "denied",
|
||||
"text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10":
|
||||
status !== "denied",
|
||||
})}
|
||||
onClick={() => onDenyPatternChange(editState.value)}
|
||||
aria-label={t(
|
||||
status === "denied"
|
||||
? "chat:commandExecution.removeFromDenied"
|
||||
: "chat:commandExecution.addToDenied",
|
||||
)}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,17 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => <TooltipPro
|
|||
describe("CommandPatternSelector", () => {
|
||||
const defaultProps = {
|
||||
command: "npm install express",
|
||||
patterns: [
|
||||
{ pattern: "npm install", description: "Install npm packages" },
|
||||
{ pattern: "npm *", description: "Any npm command" },
|
||||
],
|
||||
allowedCommands: ["npm install"],
|
||||
deniedCommands: ["git push"],
|
||||
onAllowCommandChange: vi.fn(),
|
||||
onDenyCommandChange: vi.fn(),
|
||||
onAllowPatternChange: vi.fn(),
|
||||
onDenyPatternChange: vi.fn(),
|
||||
}
|
||||
|
||||
it("should render with command input", () => {
|
||||
it("should render with command permissions header", () => {
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<CommandPatternSelector {...defaultProps} />
|
||||
|
|
@ -43,16 +47,11 @@ describe("CommandPatternSelector", () => {
|
|||
// The component should render without errors
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Check that the input is rendered with the command
|
||||
const input = screen.getByDisplayValue("npm install express")
|
||||
expect(input).toBeInTheDocument()
|
||||
// Check for the command permissions text
|
||||
expect(screen.getByText("chat:commandExecution.commandPermissions")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should allow editing the command", () => {
|
||||
it("should show full command as first pattern when expanded", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<CommandPatternSelector {...defaultProps} />
|
||||
|
|
@ -60,42 +59,79 @@ describe("CommandPatternSelector", () => {
|
|||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Get the input and change its value
|
||||
const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: "npm install react" } })
|
||||
// Check that the full command is shown with description
|
||||
expect(screen.getByText("npm install express")).toBeInTheDocument()
|
||||
expect(screen.getByText("- Full command")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check that the input value has changed
|
||||
it("should show extracted patterns when expanded", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<CommandPatternSelector {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Check that patterns are shown
|
||||
expect(screen.getByText("npm install")).toBeInTheDocument()
|
||||
expect(screen.getByText("- Install npm packages")).toBeInTheDocument()
|
||||
expect(screen.getByText("npm *")).toBeInTheDocument()
|
||||
expect(screen.getByText("- Any npm command")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should allow editing patterns when clicked", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<CommandPatternSelector {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Click on the full command pattern
|
||||
const fullCommandDiv = screen.getByText("npm install express").closest("div")
|
||||
fireEvent.click(fullCommandDiv!)
|
||||
|
||||
// An input should appear
|
||||
const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
|
||||
expect(input).toBeInTheDocument()
|
||||
|
||||
// Change the value
|
||||
fireEvent.change(input, { target: { value: "npm install react" } })
|
||||
expect(input.value).toBe("npm install react")
|
||||
})
|
||||
|
||||
it("should show allowed status for commands in allowed list", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
command: "npm install",
|
||||
}
|
||||
|
||||
it("should show allowed status for patterns in allowed list", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<CommandPatternSelector {...props} />
|
||||
<CommandPatternSelector {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Find the npm install pattern row
|
||||
const npmInstallPattern = screen.getByText("npm install").closest(".ml-5")
|
||||
|
||||
// The allow button should have the active styling (we can check by aria-label)
|
||||
const allowButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromAllowed/i })
|
||||
const allowButton = npmInstallPattern?.querySelector('button[aria-label*="removeFromAllowed"]')
|
||||
expect(allowButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should show denied status for commands in denied list", () => {
|
||||
it("should show denied status for patterns in denied list", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
command: "git push",
|
||||
patterns: [{ pattern: "git push", description: "Push to git" }],
|
||||
}
|
||||
|
||||
render(
|
||||
|
|
@ -105,19 +141,22 @@ describe("CommandPatternSelector", () => {
|
|||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Find the git push pattern row
|
||||
const gitPushPattern = screen.getByText("git push").closest(".ml-5")
|
||||
|
||||
// The deny button should have the active styling (we can check by aria-label)
|
||||
const denyButton = screen.getByRole("button", { name: /chat:commandExecution.removeFromDenied/i })
|
||||
const denyButton = gitPushPattern?.querySelector('button[aria-label*="removeFromDenied"]')
|
||||
expect(denyButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should call onAllowCommandChange when allow button is clicked", () => {
|
||||
const mockOnAllowCommandChange = vi.fn()
|
||||
it("should call onAllowPatternChange when allow button is clicked", () => {
|
||||
const mockOnAllowPatternChange = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAllowCommandChange: mockOnAllowCommandChange,
|
||||
onAllowPatternChange: mockOnAllowPatternChange,
|
||||
}
|
||||
|
||||
render(
|
||||
|
|
@ -127,22 +166,23 @@ describe("CommandPatternSelector", () => {
|
|||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Click the allow button
|
||||
const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i })
|
||||
fireEvent.click(allowButton)
|
||||
// Find the full command pattern row and click allow
|
||||
const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5")
|
||||
const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]')
|
||||
fireEvent.click(allowButton!)
|
||||
|
||||
// Check that the callback was called with the command
|
||||
expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install express")
|
||||
// Check that the callback was called with the pattern
|
||||
expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install express")
|
||||
})
|
||||
|
||||
it("should call onDenyCommandChange when deny button is clicked", () => {
|
||||
const mockOnDenyCommandChange = vi.fn()
|
||||
it("should call onDenyPatternChange when deny button is clicked", () => {
|
||||
const mockOnDenyPatternChange = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onDenyCommandChange: mockOnDenyCommandChange,
|
||||
onDenyPatternChange: mockOnDenyPatternChange,
|
||||
}
|
||||
|
||||
render(
|
||||
|
|
@ -152,22 +192,23 @@ describe("CommandPatternSelector", () => {
|
|||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Click the deny button
|
||||
const denyButton = screen.getByRole("button", { name: /chat:commandExecution.addToDenied/i })
|
||||
fireEvent.click(denyButton)
|
||||
// Find the full command pattern row and click deny
|
||||
const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5")
|
||||
const denyButton = fullCommandPattern?.querySelector('button[aria-label*="addToDenied"]')
|
||||
fireEvent.click(denyButton!)
|
||||
|
||||
// Check that the callback was called with the command
|
||||
expect(mockOnDenyCommandChange).toHaveBeenCalledWith("npm install express")
|
||||
// Check that the callback was called with the pattern
|
||||
expect(mockOnDenyPatternChange).toHaveBeenCalledWith("npm install express")
|
||||
})
|
||||
|
||||
it("should use edited command value when buttons are clicked", () => {
|
||||
const mockOnAllowCommandChange = vi.fn()
|
||||
it("should use edited pattern value when buttons are clicked", () => {
|
||||
const mockOnAllowPatternChange = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAllowCommandChange: mockOnAllowCommandChange,
|
||||
onAllowPatternChange: mockOnAllowPatternChange,
|
||||
}
|
||||
|
||||
render(
|
||||
|
|
@ -177,18 +218,53 @@ describe("CommandPatternSelector", () => {
|
|||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Click on the full command pattern to edit
|
||||
const fullCommandDiv = screen.getByText("npm install express").closest("div")
|
||||
fireEvent.click(fullCommandDiv!)
|
||||
|
||||
// Edit the command
|
||||
const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: "npm install react" } })
|
||||
|
||||
// Click the allow button
|
||||
const allowButton = screen.getByRole("button", { name: /chat:commandExecution.addToAllowed/i })
|
||||
fireEvent.click(allowButton)
|
||||
// Press Enter to confirm edit
|
||||
fireEvent.keyDown(input, { key: "Enter" })
|
||||
|
||||
// Check that the callback was called with the edited command
|
||||
expect(mockOnAllowCommandChange).toHaveBeenCalledWith("npm install react")
|
||||
// Click the allow button
|
||||
const fullCommandPattern = screen.getByText("npm install react").closest(".ml-5")
|
||||
const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]')
|
||||
fireEvent.click(allowButton!)
|
||||
|
||||
// Check that the callback was called with the edited pattern
|
||||
expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install react")
|
||||
})
|
||||
|
||||
it("should cancel edit on Escape key", () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<CommandPatternSelector {...defaultProps} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Click to expand the component
|
||||
const expandButton = screen.getByRole("button")
|
||||
fireEvent.click(expandButton)
|
||||
|
||||
// Click on the full command pattern to edit
|
||||
const fullCommandDiv = screen.getByText("npm install express").closest("div")
|
||||
fireEvent.click(fullCommandDiv!)
|
||||
|
||||
// Edit the command
|
||||
const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: "npm install react" } })
|
||||
|
||||
// Press Escape to cancel
|
||||
fireEvent.keyDown(input, { key: "Escape" })
|
||||
|
||||
// The original value should be restored
|
||||
expect(screen.getByText("npm install express")).toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue("npm install react")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
137
webview-ui/src/utils/__tests__/command-parser.spec.ts
Normal file
137
webview-ui/src/utils/__tests__/command-parser.spec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { extractPatternsFromCommand } from "../command-parser"
|
||||
|
||||
describe("extractPatternsFromCommand", () => {
|
||||
it("should extract simple command pattern", () => {
|
||||
const patterns = extractPatternsFromCommand("ls")
|
||||
expect(patterns).toEqual(["ls"])
|
||||
})
|
||||
|
||||
it("should extract command with subcommand", () => {
|
||||
const patterns = extractPatternsFromCommand("git push origin main")
|
||||
expect(patterns).toEqual(["git", "git push", "git push origin"])
|
||||
})
|
||||
|
||||
it("should stop at flags", () => {
|
||||
const patterns = extractPatternsFromCommand("git commit -m 'test'")
|
||||
expect(patterns).toEqual(["git", "git commit"])
|
||||
})
|
||||
|
||||
it("should stop at paths", () => {
|
||||
const patterns = extractPatternsFromCommand("cd /usr/local/bin")
|
||||
expect(patterns).toEqual(["cd"])
|
||||
})
|
||||
|
||||
it("should handle pipes", () => {
|
||||
const patterns = extractPatternsFromCommand("ls -la | grep test")
|
||||
expect(patterns).toEqual(["grep", "grep test", "ls"])
|
||||
})
|
||||
|
||||
it("should handle && operator", () => {
|
||||
const patterns = extractPatternsFromCommand("npm install && git push origin main")
|
||||
expect(patterns).toEqual(["git", "git push", "git push origin", "npm", "npm install"])
|
||||
})
|
||||
|
||||
it("should handle || operator", () => {
|
||||
const patterns = extractPatternsFromCommand("npm test || npm run test:ci")
|
||||
expect(patterns).toEqual(["npm", "npm run", "npm test"])
|
||||
})
|
||||
|
||||
it("should handle semicolon separator", () => {
|
||||
const patterns = extractPatternsFromCommand("cd src; npm install")
|
||||
expect(patterns).toEqual(["cd", "cd src", "npm", "npm install"])
|
||||
})
|
||||
|
||||
it("should skip numeric commands", () => {
|
||||
const patterns = extractPatternsFromCommand("0 total")
|
||||
expect(patterns).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle empty command", () => {
|
||||
const patterns = extractPatternsFromCommand("")
|
||||
expect(patterns).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle null/undefined", () => {
|
||||
expect(extractPatternsFromCommand(null as any)).toEqual([])
|
||||
expect(extractPatternsFromCommand(undefined as any)).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle scripts", () => {
|
||||
const patterns = extractPatternsFromCommand("./script.sh --verbose")
|
||||
expect(patterns).toEqual(["./script.sh"])
|
||||
})
|
||||
|
||||
it("should handle paths with dots", () => {
|
||||
const patterns = extractPatternsFromCommand("git add .")
|
||||
expect(patterns).toEqual(["git", "git add"])
|
||||
})
|
||||
|
||||
it("should handle paths with tilde", () => {
|
||||
const patterns = extractPatternsFromCommand("cd ~/projects")
|
||||
expect(patterns).toEqual(["cd"])
|
||||
})
|
||||
|
||||
it("should handle colons in arguments", () => {
|
||||
const patterns = extractPatternsFromCommand("docker run image:tag")
|
||||
expect(patterns).toEqual(["docker", "docker run"])
|
||||
})
|
||||
|
||||
it("should return sorted patterns", () => {
|
||||
const patterns = extractPatternsFromCommand("npm run build && git push")
|
||||
expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"])
|
||||
})
|
||||
|
||||
it("should handle complex command with multiple operators", () => {
|
||||
const patterns = extractPatternsFromCommand("npm install && npm test | grep success || echo 'failed'")
|
||||
expect(patterns).toContain("npm")
|
||||
expect(patterns).toContain("npm install")
|
||||
expect(patterns).toContain("npm test")
|
||||
expect(patterns).toContain("grep")
|
||||
expect(patterns).toContain("echo")
|
||||
})
|
||||
|
||||
it("should handle malformed commands gracefully", () => {
|
||||
const patterns = extractPatternsFromCommand("echo 'unclosed quote")
|
||||
expect(patterns).toContain("echo")
|
||||
})
|
||||
|
||||
it("should not treat package managers specially", () => {
|
||||
const patterns = extractPatternsFromCommand("npm run build")
|
||||
expect(patterns).toEqual(["npm", "npm run", "npm run build"])
|
||||
// Now includes "npm run build" with 3-level extraction
|
||||
})
|
||||
|
||||
it("should extract at most 3 levels", () => {
|
||||
const patterns = extractPatternsFromCommand("git push origin main --force")
|
||||
expect(patterns).toEqual(["git", "git push", "git push origin"])
|
||||
// Should NOT include deeper levels beyond 3
|
||||
})
|
||||
|
||||
it("should handle multi-level commands like gh pr", () => {
|
||||
const patterns = extractPatternsFromCommand("gh pr checkout 123")
|
||||
expect(patterns).toEqual(["gh", "gh pr", "gh pr checkout"])
|
||||
})
|
||||
|
||||
it("should extract 3 levels for git remote add", () => {
|
||||
const patterns = extractPatternsFromCommand("git remote add origin https://github.com/user/repo.git")
|
||||
expect(patterns).toEqual(["git", "git remote", "git remote add"])
|
||||
})
|
||||
|
||||
it("should extract 3 levels for npm run build", () => {
|
||||
const patterns = extractPatternsFromCommand("npm run build --production")
|
||||
expect(patterns).toEqual(["npm", "npm run", "npm run build"])
|
||||
})
|
||||
|
||||
it("should stop at file extensions even at third level", () => {
|
||||
const patterns = extractPatternsFromCommand("node scripts test.js")
|
||||
expect(patterns).toEqual(["node", "node scripts"])
|
||||
// Should NOT include "node scripts test.js" because of .js
|
||||
})
|
||||
|
||||
it("should stop at flags at any level", () => {
|
||||
const patterns = extractPatternsFromCommand("docker run -it ubuntu")
|
||||
expect(patterns).toEqual(["docker", "docker run"])
|
||||
// Stops at -it flag
|
||||
})
|
||||
})
|
||||
68
webview-ui/src/utils/command-parser.ts
Normal file
68
webview-ui/src/utils/command-parser.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { parse } from "shell-quote"
|
||||
|
||||
/**
|
||||
* Extract command patterns from a command string.
|
||||
* Returns at most 3 levels: base command, command + first argument, and command + first two arguments.
|
||||
* Stops at flags (-), paths (/\~), file extensions (.ext), or special characters (:).
|
||||
*/
|
||||
export function extractPatternsFromCommand(command: string): string[] {
|
||||
if (!command?.trim()) return []
|
||||
|
||||
const patterns = new Set<string>()
|
||||
|
||||
try {
|
||||
const parsed = parse(command)
|
||||
const commandSeparators = new Set(["|", "&&", "||", ";"])
|
||||
let currentTokens: string[] = []
|
||||
|
||||
for (const token of parsed) {
|
||||
if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) {
|
||||
// Process accumulated tokens as a command
|
||||
if (currentTokens.length > 0) {
|
||||
extractFromTokens(currentTokens, patterns)
|
||||
currentTokens = []
|
||||
}
|
||||
} else if (typeof token === "string") {
|
||||
currentTokens.push(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining tokens
|
||||
if (currentTokens.length > 0) {
|
||||
extractFromTokens(currentTokens, patterns)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse command:", error)
|
||||
// Fallback: just extract the first word
|
||||
const firstWord = command.trim().split(/\s+/)[0]
|
||||
if (firstWord) patterns.add(firstWord)
|
||||
}
|
||||
|
||||
return Array.from(patterns).sort()
|
||||
}
|
||||
|
||||
function isValidToken(token: string): boolean {
|
||||
return !!token && !token.startsWith("-") && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/)
|
||||
}
|
||||
|
||||
function extractFromTokens(tokens: string[], patterns: Set<string>): void {
|
||||
if (tokens.length === 0) return
|
||||
|
||||
const mainCmd = tokens[0]
|
||||
|
||||
// Skip numeric commands like "0" from "0 total"
|
||||
if (/^\d+$/.test(mainCmd)) return
|
||||
|
||||
// Build patterns progressively up to 3 levels
|
||||
let pattern = mainCmd
|
||||
patterns.add(pattern)
|
||||
|
||||
for (let i = 1; i < Math.min(tokens.length, 3); i++) {
|
||||
if (isValidToken(tokens[i])) {
|
||||
pattern += ` ${tokens[i]}`
|
||||
patterns.add(pattern)
|
||||
} else {
|
||||
break // Stop at first invalid token
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue