mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: improve hooks settings UI
Add open project/global folder buttons at bottom Move reload to bottom Add enable-all toggle (with new message type) Convert hooks list to accordion + per-hook logs
This commit is contained in:
parent
85f2bd64dc
commit
e079e6c7aa
7 changed files with 693 additions and 93 deletions
|
|
@ -617,6 +617,7 @@ export interface WebviewMessage {
|
|||
| "debugSetting"
|
||||
| "hooksReloadConfig"
|
||||
| "hooksSetEnabled"
|
||||
| "hooksSetAllEnabled"
|
||||
| "hooksOpenConfigFolder"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
|
|
@ -675,6 +676,7 @@ export interface WebviewMessage {
|
|||
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
|
||||
hookId?: string // For hooksSetEnabled
|
||||
hookEnabled?: boolean // For hooksSetEnabled
|
||||
hooksEnabled?: boolean // For hooksSetAllEnabled
|
||||
hooksSource?: "global" | "project" // For hooksOpenConfigFolder
|
||||
codeIndexSettings?: {
|
||||
// Global state settings
|
||||
|
|
|
|||
|
|
@ -2240,16 +2240,21 @@ export class ClineProvider
|
|||
}
|
||||
|
||||
const snapshot = this.hookManager.getConfigSnapshot()
|
||||
const enabledHooks = this.hookManager.getEnabledHooks()
|
||||
const executionHistory = this.hookManager.getHookExecutionHistory()
|
||||
|
||||
// Build a list of *all* known hooks (including currently disabled ones)
|
||||
// so the webview can show per-hook toggles and a global "Enable Hooks" toggle.
|
||||
const allHooks = snapshot
|
||||
? Array.from(snapshot.hooksByEvent.values()).reduce((acc, hooks) => acc.concat(hooks), [] as any[])
|
||||
: []
|
||||
|
||||
// Convert ResolvedHook[] to HookInfo[]
|
||||
const hookInfos = enabledHooks.map((hook) => ({
|
||||
const hookInfos = allHooks.map((hook) => ({
|
||||
id: hook.id,
|
||||
event: hook.event,
|
||||
matcher: hook.matcher,
|
||||
commandPreview: hook.command.length > 100 ? hook.command.substring(0, 97) + "..." : hook.command,
|
||||
enabled: hook.enabled ?? true,
|
||||
enabled: (hook.enabled ?? true) && !(snapshot?.disabledHookIds?.has(hook.id) ?? false),
|
||||
source: hook.source,
|
||||
timeout: hook.timeout ?? 60,
|
||||
shell: hook.shell,
|
||||
|
|
|
|||
|
|
@ -227,6 +227,92 @@ describe("webviewMessageHandler - hooks commands", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("hooksSetAllEnabled", () => {
|
||||
it("should call setHookEnabled for all hooks in snapshot and postStateToWebview", async () => {
|
||||
const hooksById = new Map<string, ResolvedHook>()
|
||||
hooksById.set("hook-1", {
|
||||
id: "hook-1",
|
||||
event: "PreToolUse" as any,
|
||||
matcher: ".*",
|
||||
command: "echo 1",
|
||||
enabled: true,
|
||||
source: "global" as any,
|
||||
timeout: 30,
|
||||
includeConversationHistory: false,
|
||||
} as any)
|
||||
hooksById.set("hook-2", {
|
||||
id: "hook-2",
|
||||
event: "PostToolUse" as any,
|
||||
matcher: ".*",
|
||||
command: "echo 2",
|
||||
enabled: true,
|
||||
source: "project" as any,
|
||||
timeout: 30,
|
||||
includeConversationHistory: false,
|
||||
} as any)
|
||||
|
||||
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: false,
|
||||
} as HooksConfigSnapshot)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetAllEnabled",
|
||||
hooksEnabled: false,
|
||||
})
|
||||
|
||||
expect(mockHookManager.setHookEnabled).toHaveBeenCalledTimes(2)
|
||||
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("hook-1", false)
|
||||
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("hook-2", false)
|
||||
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not call setHookEnabled when hooksEnabled is not a boolean", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetAllEnabled",
|
||||
hooksEnabled: "false",
|
||||
} as any)
|
||||
|
||||
expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled()
|
||||
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should show error message when bulk setHookEnabled fails", async () => {
|
||||
const hooksById = new Map<string, ResolvedHook>()
|
||||
hooksById.set("hook-1", {
|
||||
id: "hook-1",
|
||||
event: "PreToolUse" as any,
|
||||
matcher: ".*",
|
||||
command: "echo 1",
|
||||
enabled: true,
|
||||
source: "global" as any,
|
||||
timeout: 30,
|
||||
includeConversationHistory: false,
|
||||
} as any)
|
||||
|
||||
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: false,
|
||||
} as HooksConfigSnapshot)
|
||||
|
||||
vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(new Error("boom"))
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksSetAllEnabled",
|
||||
hooksEnabled: true,
|
||||
})
|
||||
|
||||
expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to set all hooks enabled: boom")
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to enable all hooks")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hooksOpenConfigFolder", () => {
|
||||
it("should open project hooks folder by default", async () => {
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
|
|
|
|||
|
|
@ -3374,6 +3374,30 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
}
|
||||
|
||||
case "hooksSetAllEnabled": {
|
||||
// Enable or disable ALL currently known hooks.
|
||||
// This mirrors MCP's "Enable MCP Servers" top-level toggle.
|
||||
const hookManager = provider.getHookManager()
|
||||
if (hookManager && typeof message.hooksEnabled === "boolean") {
|
||||
try {
|
||||
const snapshot = hookManager.getConfigSnapshot()
|
||||
const allHookIds = snapshot ? Array.from(snapshot.hooksById.keys()) : []
|
||||
|
||||
for (const hookId of allHookIds) {
|
||||
await hookManager.setHookEnabled(hookId, message.hooksEnabled)
|
||||
}
|
||||
|
||||
await provider.postStateToWebview()
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`Failed to set all hooks enabled: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(`Failed to ${message.hooksEnabled ? "enable" : "disable"} all hooks`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "hooksOpenConfigFolder": {
|
||||
// Open the hooks configuration folder in VS Code
|
||||
const source = message.hooksSource ?? "project"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const HooksSettings: React.FC = () => {
|
|||
const { t } = useAppTranslation()
|
||||
const { hooks } = useExtensionState()
|
||||
const [executionHistory, setExecutionHistory] = useState<HookExecutionRecord[]>(hooks?.executionHistory || [])
|
||||
const [isUpdatingAllEnabled, setIsUpdatingAllEnabled] = useState(false)
|
||||
|
||||
// Listen for realtime hookExecutionStatus messages
|
||||
useEffect(() => {
|
||||
|
|
@ -63,54 +64,57 @@ export const HooksSettings: React.FC = () => {
|
|||
vscode.postMessage({ type: "hooksSetEnabled", hookId, hookEnabled: enabled })
|
||||
}, [])
|
||||
|
||||
const handleToggleAllHooks = useCallback((enabled: boolean) => {
|
||||
setIsUpdatingAllEnabled(true)
|
||||
vscode.postMessage({ type: "hooksSetAllEnabled", hooksEnabled: enabled })
|
||||
// Optimistically clear the "updating" flag after a short delay.
|
||||
// The extension will send updated state via postStateToWebview().
|
||||
setTimeout(() => setIsUpdatingAllEnabled(false), 500)
|
||||
}, [])
|
||||
|
||||
const enabledHooks = hooks?.enabledHooks || []
|
||||
const hasProjectHooks = hooks?.hasProjectHooks || false
|
||||
const snapshotTimestamp = hooks?.snapshotTimestamp
|
||||
const allHooksEnabled = enabledHooks.length > 0 && enabledHooks.every((h) => h.enabled)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader>{t("settings:sections.hooks")}</SectionHeader>
|
||||
|
||||
<Section>
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-medium m-0">{t("settings:hooks.configuredHooks")}</h3>
|
||||
{snapshotTimestamp && (
|
||||
<StandardTooltip
|
||||
content={t("settings:hooks.lastLoadedTooltip", {
|
||||
time: new Date(snapshotTimestamp).toLocaleString(),
|
||||
})}>
|
||||
<Clock className="w-4 h-4 text-vscode-descriptionForeground" />
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{/* Enable all hooks */}
|
||||
{enabledHooks.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-3 mb-4 p-3 rounded border border-vscode-input-border bg-vscode-input-background">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{t("settings:hooks.enableHooks")}</span>
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{t("settings:hooks.enableHooksDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allHooksEnabled}
|
||||
disabled={isUpdatingAllEnabled}
|
||||
onChange={(e) => handleToggleAllHooks(e.target.checked)}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm">{t("settings:hooks.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StandardTooltip content={t("settings:hooks.reloadTooltip")}>
|
||||
<Button variant="ghost" size="sm" onClick={handleReloadConfig}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span className="ml-1">{t("settings:hooks.reload")}</span>
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-base font-medium m-0">{t("settings:hooks.configuredHooks")}</h3>
|
||||
{snapshotTimestamp && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
hasProjectHooks
|
||||
? t("settings:hooks.openProjectFolderTooltip")
|
||||
: t("settings:hooks.openGlobalFolderTooltip")
|
||||
}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenConfigFolder(hasProjectHooks ? "project" : "global")}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span className="ml-1">
|
||||
{hasProjectHooks
|
||||
? t("settings:hooks.openProjectFolder")
|
||||
: t("settings:hooks.openGlobalFolder")}
|
||||
</span>
|
||||
</Button>
|
||||
content={t("settings:hooks.lastLoadedTooltip", {
|
||||
time: new Date(snapshotTimestamp).toLocaleString(),
|
||||
})}>
|
||||
<Clock className="w-4 h-4 text-vscode-descriptionForeground" />
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security warning for project hooks */}
|
||||
|
|
@ -160,6 +164,37 @@ export const HooksSettings: React.FC = () => {
|
|||
|
||||
{/* Hook Activity Log */}
|
||||
<HookActivityLog executionHistory={executionHistory} />
|
||||
|
||||
{/* Bottom Action Buttons - mirroring MCP settings order: global, project, refresh */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
||||
gap: "10px",
|
||||
}}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => handleOpenConfigFolder("global")}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span className="ml-2">{t("settings:hooks.openGlobalFolder")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => handleOpenConfigFolder("project")}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span className="ml-2">{t("settings:hooks.openProjectFolder")}</span>
|
||||
</Button>
|
||||
<StandardTooltip content={t("settings:hooks.reloadTooltip")}>
|
||||
<Button variant="secondary" style={{ width: "100%" }} onClick={handleReloadConfig}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span className="ml-2">{t("settings:hooks.reload")}</span>
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -172,61 +207,236 @@ interface HookItemProps {
|
|||
|
||||
const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
||||
const { t } = useAppTranslation()
|
||||
const { hooks } = useExtensionState()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [hookLogs, setHookLogs] = useState<HookExecutionRecord[]>([])
|
||||
|
||||
// Filter execution history for this specific hook
|
||||
useEffect(() => {
|
||||
const history = hooks?.executionHistory || []
|
||||
const filtered = history.filter((record) => record.hookId === hook.id)
|
||||
setHookLogs(filtered)
|
||||
}, [hooks?.executionHistory, hook.id])
|
||||
|
||||
// Listen for realtime hookExecutionStatus messages for this hook
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "hookExecutionStatus") {
|
||||
const payload: HookExecutionStatusPayload = message.hookExecutionStatus
|
||||
|
||||
// Only process messages for this specific hook
|
||||
if (payload.hookId === hook.id) {
|
||||
if (payload.status === "completed" || payload.status === "failed" || payload.status === "blocked") {
|
||||
const record: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: payload.hookId || "unknown",
|
||||
event: payload.event,
|
||||
toolName: payload.toolName,
|
||||
exitCode: payload.status === "completed" ? 0 : 1,
|
||||
duration: payload.duration || 0,
|
||||
timedOut: false,
|
||||
blocked: payload.status === "blocked",
|
||||
error: payload.error,
|
||||
blockMessage: payload.blockMessage,
|
||||
}
|
||||
|
||||
setHookLogs((prev) => [record, ...prev].slice(0, 20)) // Keep last 20 per hook
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [hook.id])
|
||||
|
||||
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation()
|
||||
onToggle(hook.id, e.target.checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded border border-vscode-input-border bg-vscode-input-background">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
|
||||
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
|
||||
{hook.matcher && (
|
||||
<>
|
||||
<span className="text-vscode-descriptionForeground">→</span>
|
||||
<code className="text-xs font-mono text-vscode-descriptionForeground">
|
||||
{hook.matcher}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto text-xs px-2 py-0.5 rounded ${
|
||||
hook.source === "project"
|
||||
? "bg-yellow-500/20 text-yellow-500"
|
||||
: hook.source === "mode"
|
||||
? "bg-blue-500/20 text-blue-500"
|
||||
: "bg-gray-500/20 text-gray-400"
|
||||
}`}>
|
||||
{hook.source}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hook.description && <p className="text-sm text-vscode-foreground mb-2">{hook.description}</p>}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-vscode-descriptionForeground">
|
||||
<code className="font-mono bg-vscode-editor-background px-2 py-1 rounded">
|
||||
{hook.commandPreview}
|
||||
</code>
|
||||
{hook.shell && (
|
||||
<span>
|
||||
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t("settings:hooks.timeout")}: {hook.timeout}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded border border-vscode-input-border bg-vscode-input-background">
|
||||
{/* Collapsed Header */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-vscode-list-hoverBackground"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<span
|
||||
className="transform transition-transform"
|
||||
style={{ transform: isExpanded ? "rotate(90deg)" : "" }}>
|
||||
▶
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Zap className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
|
||||
<code className="text-sm font-mono text-vscode-textLink-foreground truncate">{hook.id}</code>
|
||||
<span
|
||||
className={`ml-auto text-xs px-2 py-0.5 rounded flex-shrink-0 ${
|
||||
hook.source === "project"
|
||||
? "bg-yellow-500/20 text-yellow-500"
|
||||
: hook.source === "mode"
|
||||
? "bg-blue-500/20 text-blue-500"
|
||||
: "bg-gray-500/20 text-gray-400"
|
||||
}`}>
|
||||
{hook.source}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-shrink-0">
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hook.enabled}
|
||||
onChange={(e) => onToggle(hook.id, e.target.checked)}
|
||||
onChange={handleToggle}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm">{t("settings:hooks.enabled")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0 space-y-3 border-t border-vscode-input-border">
|
||||
{/* Hook Details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{t("settings:hooks.event")}:
|
||||
</span>
|
||||
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
|
||||
</div>
|
||||
{hook.matcher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{t("settings:hooks.matcher")}:
|
||||
</span>
|
||||
<code className="text-xs font-mono text-vscode-descriptionForeground">
|
||||
{hook.matcher}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{hook.description && (
|
||||
<div>
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{t("settings:hooks.description")}:
|
||||
</span>
|
||||
<p className="text-sm text-vscode-foreground mt-1">{hook.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-vscode-descriptionForeground">
|
||||
{t("settings:hooks.command")}:
|
||||
</span>
|
||||
<code className="text-xs font-mono bg-vscode-editor-background px-2 py-1 rounded flex-1">
|
||||
{hook.commandPreview}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-vscode-descriptionForeground">
|
||||
{hook.shell && (
|
||||
<span>
|
||||
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{t("settings:hooks.timeout")}: {hook.timeout}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Section */}
|
||||
<div className="border-t border-vscode-input-border pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium">{t("settings:hooks.logs")}</span>
|
||||
{hookLogs.length > 0 && (
|
||||
<span className="text-xs text-vscode-descriptionForeground">({hookLogs.length})</span>
|
||||
)}
|
||||
</div>
|
||||
{hookLogs.length === 0 ? (
|
||||
<div className="text-xs text-vscode-descriptionForeground py-2">
|
||||
{t("settings:hooks.noLogsForHook")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{hookLogs.map((record, index) => (
|
||||
<HookLogItem key={`${record.timestamp}-${index}`} record={record} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HookLogItemProps {
|
||||
record: HookExecutionRecord
|
||||
}
|
||||
|
||||
const HookLogItem: React.FC<HookLogItemProps> = ({ record }) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
if (record.blocked) {
|
||||
return {
|
||||
label: t("settings:hooks.status.blocked"),
|
||||
className: "bg-red-500/20 text-red-500",
|
||||
icon: <X className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
if (record.error || record.exitCode !== 0) {
|
||||
return {
|
||||
label: t("settings:hooks.status.failed"),
|
||||
className: "bg-red-500/20 text-red-500",
|
||||
icon: <X className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
if (record.timedOut) {
|
||||
return {
|
||||
label: t("settings:hooks.status.timeout"),
|
||||
className: "bg-yellow-500/20 text-yellow-500",
|
||||
icon: <Clock className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: t("settings:hooks.status.completed"),
|
||||
className: "bg-green-500/20 text-green-500",
|
||||
icon: <Zap className="w-3 h-3" />,
|
||||
}
|
||||
}
|
||||
|
||||
const status = getStatusDisplay()
|
||||
const timestamp = new Date(record.timestamp)
|
||||
const timeAgo = getTimeAgo(timestamp)
|
||||
|
||||
return (
|
||||
<div className="p-2 rounded border border-vscode-input-border bg-vscode-editor-background text-xs">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${status.className}`}>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
{record.toolName && (
|
||||
<code className="text-xs font-mono text-vscode-descriptionForeground truncate">
|
||||
{record.toolName}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground flex-shrink-0">
|
||||
<span>{record.duration}ms</span>
|
||||
<StandardTooltip content={timestamp.toLocaleString()}>
|
||||
<span className="cursor-help">{timeAgo}</span>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(record.error || record.blockMessage) && (
|
||||
<div className="mt-1 p-2 rounded bg-vscode-input-background text-xs font-mono text-red-400 overflow-x-auto">
|
||||
{record.blockMessage || record.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,9 +104,173 @@ describe("HooksSettings", () => {
|
|||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Hook ID should be visible in collapsed state
|
||||
expect(screen.getByText(mockHook.id)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockHook.source)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("expands and collapses hook accordion on click", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
matcher: "git*",
|
||||
commandPreview: "echo 'Before git command'",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
description: "Test hook",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Hook details should not be visible initially
|
||||
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockHook.matcher!)).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
const hookHeader = screen.getByText(mockHook.id).closest("div")
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
// Hook details should now be visible
|
||||
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument()
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
// Hook details should be hidden again
|
||||
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows per-hook logs in expanded view", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
}
|
||||
|
||||
const mockRecord: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
toolName: "write_to_file",
|
||||
exitCode: 0,
|
||||
duration: 150,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [mockRecord],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Expand hook
|
||||
const hookHeader = screen.getByText(mockHook.id).closest("div")
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
// Logs section should be visible
|
||||
expect(screen.getByText("settings:hooks.logs")).toBeInTheDocument()
|
||||
expect(screen.getByText(mockRecord.toolName!)).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("filters logs per hook correctly", () => {
|
||||
const mockHook1: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
}
|
||||
|
||||
const mockHook2: HookInfo = {
|
||||
id: "hook-2",
|
||||
event: "after_execute_command",
|
||||
commandPreview: "echo after",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
}
|
||||
|
||||
const record1: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
toolName: "write_to_file",
|
||||
exitCode: 0,
|
||||
duration: 100,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
}
|
||||
|
||||
const record2: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-2",
|
||||
event: "after_execute_command",
|
||||
toolName: "read_file",
|
||||
exitCode: 0,
|
||||
duration: 50,
|
||||
timedOut: false,
|
||||
blocked: false,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook1, mockHook2],
|
||||
executionHistory: [record1, record2],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Expand first hook
|
||||
const hook1Headers = screen.getAllByText("hook-1")
|
||||
const hook1Header = hook1Headers[0].closest("div")
|
||||
fireEvent.click(hook1Header!)
|
||||
|
||||
// Should show only hook-1's log
|
||||
expect(screen.getByText("write_to_file")).toBeInTheDocument()
|
||||
expect(screen.queryByText("read_file")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows 'no logs' message when hook has no execution history", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Expand hook
|
||||
const hookHeader = screen.getByText(mockHook.id).closest("div")
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
// Should show no logs message
|
||||
expect(screen.getByText("settings:hooks.noLogsForHook")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows project hooks warning when hasProjectHooks is true", () => {
|
||||
|
|
@ -121,27 +285,40 @@ describe("HooksSettings", () => {
|
|||
expect(screen.getByText("settings:hooks.projectHooksWarningMessage")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("sends hooksReloadConfig message when Reload button is clicked", async () => {
|
||||
it("sends hooksReloadConfig message when Reload button is clicked (bottom action)", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Reload button is now in bottom action area (mirroring MCP settings)
|
||||
const reloadButton = screen.getByText("settings:hooks.reload")
|
||||
fireEvent.click(reloadButton)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "hooksReloadConfig" })
|
||||
})
|
||||
|
||||
it("sends hooksOpenConfigFolder message when Open Folder button is clicked", async () => {
|
||||
it("sends hooksOpenConfigFolder message with 'global' when Global Folder button is clicked (bottom action)", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
currentHooksState = {
|
||||
...mockHooksState,
|
||||
hasProjectHooks: true,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
const openFolderButton = screen.getByText("settings:hooks.openProjectFolder")
|
||||
fireEvent.click(openFolderButton)
|
||||
// Button is now in bottom action area (like MCP settings)
|
||||
const globalFolderButton = screen.getByText("settings:hooks.openGlobalFolder")
|
||||
fireEvent.click(globalFolderButton)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksOpenConfigFolder",
|
||||
hooksSource: "global",
|
||||
})
|
||||
})
|
||||
|
||||
it("sends hooksOpenConfigFolder message with 'project' when Project Folder button is clicked (bottom action)", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Button is now in bottom action area (like MCP settings)
|
||||
const projectFolderButton = screen.getByText("settings:hooks.openProjectFolder")
|
||||
fireEvent.click(projectFolderButton)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksOpenConfigFolder",
|
||||
|
|
@ -149,6 +326,19 @@ describe("HooksSettings", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("renders both Global and Project folder buttons in bottom action area regardless of hasProjectHooks state", () => {
|
||||
currentHooksState = {
|
||||
...mockHooksState,
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Both buttons should be present
|
||||
expect(screen.getByText("settings:hooks.openGlobalFolder")).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.openProjectFolder")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("sends hooksSetEnabled message when hook toggle is changed", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
|
|
@ -168,8 +358,9 @@ describe("HooksSettings", () => {
|
|||
|
||||
render(<HooksSettings />)
|
||||
|
||||
const checkbox = screen.getByRole("checkbox")
|
||||
fireEvent.click(checkbox)
|
||||
// First checkbox is the top-level "Enable Hooks" toggle; the second is the per-hook toggle in collapsed header
|
||||
const checkboxes = screen.getAllByRole("checkbox")
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksSetEnabled",
|
||||
|
|
@ -178,6 +369,80 @@ describe("HooksSettings", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("toggles hook enabled state in collapsed view without expanding accordion", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Hook should be collapsed initially
|
||||
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
|
||||
|
||||
// Click the checkbox (second checkbox, first is "Enable Hooks")
|
||||
const checkboxes = screen.getAllByRole("checkbox")
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
// Hook should still be collapsed after toggling
|
||||
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
|
||||
|
||||
// Toggle message should have been sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksSetEnabled",
|
||||
hookId: "hook-1",
|
||||
hookEnabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("sends hooksSetAllEnabled message when top-level Enable Hooks toggle is changed", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [
|
||||
{
|
||||
id: "hook-1",
|
||||
event: "event1",
|
||||
commandPreview: "cmd1",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
},
|
||||
{
|
||||
id: "hook-2",
|
||||
event: "event2",
|
||||
commandPreview: "cmd2",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
},
|
||||
],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
const checkboxes = screen.getAllByRole("checkbox")
|
||||
fireEvent.click(checkboxes[0])
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksSetAllEnabled",
|
||||
hooksEnabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("renders execution history when available", () => {
|
||||
const mockRecord: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -63,9 +63,17 @@
|
|||
},
|
||||
"noHooksConfigured": "No hooks configured",
|
||||
"noHooksHint": "Create hook configuration files to automate actions on tool execution events.",
|
||||
"enableHooks": "Enable Hooks",
|
||||
"enableHooksDescription": "Toggle all hooks on or off at once",
|
||||
"enabled": "Enabled",
|
||||
"event": "Event",
|
||||
"matcher": "Matcher",
|
||||
"description": "Description",
|
||||
"command": "Command",
|
||||
"shell": "Shell",
|
||||
"timeout": "Timeout",
|
||||
"logs": "Logs",
|
||||
"noLogsForHook": "No execution logs for this hook yet",
|
||||
"activityLog": "Hook Activity",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue