feat: implement hooks system for project configuration
- Add HooksEditor component for managing project hooks - Add ProjectSettings component for project-specific configurations - Create hooksManager utility for hook operations - Add hooks type definitions - Update backend commands to support hooks functionality - Integrate hooks into main app, agent execution, and Claude sessions - Update API and utilities to handle hooks data
This commit is contained in:
@@ -11,12 +11,21 @@ import {
|
||||
Copy,
|
||||
ChevronDown,
|
||||
Maximize2,
|
||||
X
|
||||
X,
|
||||
Settings2
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover } from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { api, type Agent } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
@@ -25,6 +34,8 @@ import { StreamMessage } from "./StreamMessage";
|
||||
import { ExecutionControlBar } from "./ExecutionControlBar";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { AGENT_ICONS } from "./CCAgents";
|
||||
import { HooksEditor } from "./HooksEditor";
|
||||
|
||||
interface AgentExecutionProps {
|
||||
/**
|
||||
@@ -78,6 +89,10 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
||||
|
||||
// Hooks configuration state
|
||||
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
|
||||
const [activeHooksTab, setActiveHooksTab] = useState("project");
|
||||
|
||||
// Execution stats
|
||||
const [executionStartTime, setExecutionStartTime] = useState<number | null>(null);
|
||||
const [totalTokens, setTotalTokens] = useState(0);
|
||||
@@ -266,6 +281,10 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenHooksDialog = async () => {
|
||||
setIsHooksDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
setIsRunning(true);
|
||||
@@ -501,86 +520,41 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4"
|
||||
className="p-6"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBackWithConfirmation}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderIcon()}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{agent.name}</h2>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-green-600 font-medium">Running</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBackWithConfirmation}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||
{renderIcon()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Execute: {agent.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isRunning ? "Click back to return to main menu - view in CC Agents > Running Sessions" : "Execute CC Agent"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreenModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen
|
||||
</Button>
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<div className="w-44 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleCopyAsJsonl}
|
||||
>
|
||||
Copy as JSONL
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
Copy as Markdown
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={copyPopoverOpen}
|
||||
onOpenChange={setCopyPopoverOpen}
|
||||
align="end"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreenModalOpen(true)}
|
||||
disabled={messages.length === 0}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
Fullscreen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -620,6 +594,15 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenHooksDialog}
|
||||
disabled={isRunning || !projectPath}
|
||||
title="Configure hooks"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
Hooks
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -931,9 +914,56 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hooks Configuration Dialog */}
|
||||
<Dialog
|
||||
open={isHooksDialogOpen}
|
||||
onOpenChange={setIsHooksDialogOpen}
|
||||
>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Hooks</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure hooks that run before, during, and after tool executions. Changes are saved immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeHooksTab} onValueChange={setActiveHooksTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="project">Project Settings</TabsTrigger>
|
||||
<TabsTrigger value="local">Local Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="project" className="flex-1 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Project hooks are stored in <code className="bg-muted px-1 py-0.5 rounded">.claude/settings.json</code> and
|
||||
are committed to version control.
|
||||
</p>
|
||||
<HooksEditor
|
||||
projectPath={projectPath}
|
||||
scope="project"
|
||||
className="border-0"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="flex-1 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Local hooks are stored in <code className="bg-muted px-1 py-0.5 rounded">.claude/settings.local.json</code> and
|
||||
are not committed to version control.
|
||||
</p>
|
||||
<HooksEditor
|
||||
projectPath={projectPath}
|
||||
scope="local"
|
||||
className="border-0"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Import AGENT_ICONS for icon rendering
|
||||
import { AGENT_ICONS } from "./CCAgents";
|
||||
|
@@ -8,7 +8,6 @@ import {
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
Settings,
|
||||
Globe,
|
||||
ChevronUp,
|
||||
X,
|
||||
Hash
|
||||
@@ -46,6 +45,10 @@ interface ClaudeCodeSessionProps {
|
||||
* Callback to go back
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Callback to open hooks configuration
|
||||
*/
|
||||
onProjectSettings?: (projectPath: string) => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
@@ -66,6 +69,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
session,
|
||||
initialProjectPath = "",
|
||||
onBack,
|
||||
onProjectSettings,
|
||||
className,
|
||||
onStreamingChange,
|
||||
}) => {
|
||||
@@ -792,8 +796,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
// Keep the previewUrl so it can be restored when reopening
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handlePreviewUrlChange = (url: string) => {
|
||||
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
||||
setPreviewUrl(url);
|
||||
@@ -971,107 +973,110 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Claude Code Session</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{session ? `Resuming session ${session.id.slice(0, 8)}...` : 'Interactive session'}
|
||||
<Terminal className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold">Claude Code Session</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectPath ? `${projectPath}` : "No project selected"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{effectiveSession && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowTimeline(!showTimeline)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Timeline
|
||||
</Button>
|
||||
</>
|
||||
{projectPath && onProjectSettings && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onProjectSettings(projectPath)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Hooks
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Preview Button */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!showPreview) {
|
||||
// Open with current URL or empty URL to show the instruction state
|
||||
setShowPreview(true);
|
||||
} else {
|
||||
handleClosePreview();
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
{showPreview ? "Close Preview" : "Preview"}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showPreview
|
||||
? "Close the preview pane"
|
||||
: "Open a browser preview to test your web applications"
|
||||
<div className="flex items-center gap-2">
|
||||
{showSettings && (
|
||||
<CheckpointSettings
|
||||
sessionId={effectiveSession?.id || ''}
|
||||
projectId={effectiveSession?.project_id || ''}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Settings className={cn("h-4 w-4", showSettings && "text-primary")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Checkpoint Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{effectiveSession && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowTimeline(!showTimeline)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<GitBranch className={cn("h-4 w-4", showTimeline && "text-primary")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Timeline Navigator</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<div className="w-44 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAsMarkdown}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
Copy as Markdown
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAsJsonl}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
Copy as JSONL
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={copyPopoverOpen}
|
||||
onOpenChange={setCopyPopoverOpen}
|
||||
/>
|
||||
)}
|
||||
content={
|
||||
<div className="w-44 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAsMarkdown}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
Copy as Markdown
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyAsJsonl}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
Copy as JSONL
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={copyPopoverOpen}
|
||||
onOpenChange={setCopyPopoverOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
@@ -11,6 +11,7 @@ import MDEditor from "@uiw/react-md-editor";
|
||||
import { type AgentIconName } from "./CCAgents";
|
||||
import { IconPicker, ICON_MAP } from "./IconPicker";
|
||||
|
||||
|
||||
interface CreateAgentProps {
|
||||
/**
|
||||
* Optional agent to edit (if provided, component is in edit mode)
|
||||
@@ -175,11 +176,11 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-4">Basic Information</h3>
|
||||
</div>
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-4">Basic Information</h3>
|
||||
</div>
|
||||
|
||||
{/* Name and Icon */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -292,8 +293,6 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* System Prompt Editor */}
|
||||
<div className="space-y-2">
|
||||
<Label>System Prompt</Label>
|
||||
@@ -314,28 +313,28 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
|
||||
{/* Icon Picker Dialog */}
|
||||
<IconPicker
|
||||
value={selectedIcon}
|
||||
onSelect={(iconName) => {
|
||||
setSelectedIcon(iconName as AgentIconName);
|
||||
setShowIconPicker(false);
|
||||
}}
|
||||
isOpen={showIconPicker}
|
||||
onClose={() => setShowIconPicker(false)}
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ToastContainer>
|
||||
|
||||
{/* Icon Picker Dialog */}
|
||||
<IconPicker
|
||||
value={selectedIcon}
|
||||
onSelect={(iconName) => {
|
||||
setSelectedIcon(iconName as AgentIconName);
|
||||
setShowIconPicker(false);
|
||||
}}
|
||||
isOpen={showIconPicker}
|
||||
onClose={() => setShowIconPicker(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
930
src/components/HooksEditor.tsx
Normal file
930
src/components/HooksEditor.tsx
Normal file
@@ -0,0 +1,930 @@
|
||||
/**
|
||||
* HooksEditor component for managing Claude Code hooks configuration
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Code2,
|
||||
Terminal,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Zap,
|
||||
Shield,
|
||||
PlayCircle,
|
||||
Info,
|
||||
Save,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { HooksManager } from '@/lib/hooksManager';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
HooksConfiguration,
|
||||
HookEvent,
|
||||
HookMatcher,
|
||||
HookCommand,
|
||||
HookTemplate,
|
||||
COMMON_TOOL_MATCHERS,
|
||||
HOOK_TEMPLATES,
|
||||
} from '@/types/hooks';
|
||||
|
||||
interface HooksEditorProps {
|
||||
projectPath?: string;
|
||||
scope: 'project' | 'local' | 'user';
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
onChange?: (hasChanges: boolean, getHooks: () => HooksConfiguration) => void;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
interface EditableHookCommand extends HookCommand {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface EditableHookMatcher extends Omit<HookMatcher, 'hooks'> {
|
||||
id: string;
|
||||
hooks: EditableHookCommand[];
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
const EVENT_INFO: Record<HookEvent, { label: string; description: string; icon: React.ReactNode }> = {
|
||||
PreToolUse: {
|
||||
label: 'Pre Tool Use',
|
||||
description: 'Runs before tool calls, can block and provide feedback',
|
||||
icon: <Shield className="h-4 w-4" />
|
||||
},
|
||||
PostToolUse: {
|
||||
label: 'Post Tool Use',
|
||||
description: 'Runs after successful tool completion',
|
||||
icon: <PlayCircle className="h-4 w-4" />
|
||||
},
|
||||
Notification: {
|
||||
label: 'Notification',
|
||||
description: 'Customizes notifications when Claude needs attention',
|
||||
icon: <Zap className="h-4 w-4" />
|
||||
},
|
||||
Stop: {
|
||||
label: 'Stop',
|
||||
description: 'Runs when Claude finishes responding',
|
||||
icon: <Code2 className="h-4 w-4" />
|
||||
},
|
||||
SubagentStop: {
|
||||
label: 'Subagent Stop',
|
||||
description: 'Runs when a Claude subagent (Task) finishes',
|
||||
icon: <Terminal className="h-4 w-4" />
|
||||
}
|
||||
};
|
||||
|
||||
export const HooksEditor: React.FC<HooksEditorProps> = ({
|
||||
projectPath,
|
||||
scope,
|
||||
readOnly = false,
|
||||
className,
|
||||
onChange,
|
||||
hideActions = false
|
||||
}) => {
|
||||
const [selectedEvent, setSelectedEvent] = useState<HookEvent>('PreToolUse');
|
||||
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||
const isInitialMount = React.useRef(true);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [hooks, setHooks] = useState<HooksConfiguration>({});
|
||||
|
||||
// Events with matchers (tool-related)
|
||||
const matcherEvents = ['PreToolUse', 'PostToolUse'] as const;
|
||||
// Events without matchers (non-tool-related)
|
||||
const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const;
|
||||
|
||||
// Convert hooks to editable format with IDs
|
||||
const [editableHooks, setEditableHooks] = useState<{
|
||||
PreToolUse: EditableHookMatcher[];
|
||||
PostToolUse: EditableHookMatcher[];
|
||||
Notification: EditableHookCommand[];
|
||||
Stop: EditableHookCommand[];
|
||||
SubagentStop: EditableHookCommand[];
|
||||
}>(() => {
|
||||
const result = {
|
||||
PreToolUse: [],
|
||||
PostToolUse: [],
|
||||
Notification: [],
|
||||
Stop: [],
|
||||
SubagentStop: []
|
||||
} as any;
|
||||
|
||||
// Initialize matcher events
|
||||
matcherEvents.forEach(event => {
|
||||
const matchers = hooks?.[event] as HookMatcher[] | undefined;
|
||||
if (matchers && Array.isArray(matchers)) {
|
||||
result[event] = matchers.map(matcher => ({
|
||||
...matcher,
|
||||
id: HooksManager.generateId(),
|
||||
expanded: false,
|
||||
hooks: (matcher.hooks || []).map(hook => ({
|
||||
...hook,
|
||||
id: HooksManager.generateId()
|
||||
}))
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize direct events
|
||||
directEvents.forEach(event => {
|
||||
const commands = hooks?.[event] as HookCommand[] | undefined;
|
||||
if (commands && Array.isArray(commands)) {
|
||||
result[event] = commands.map(hook => ({
|
||||
...hook,
|
||||
id: HooksManager.generateId()
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Load hooks when projectPath or scope changes
|
||||
useEffect(() => {
|
||||
// For user scope, we don't need a projectPath
|
||||
if (scope === 'user' || projectPath) {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
api.getHooksConfig(scope, projectPath)
|
||||
.then((config) => {
|
||||
setHooks(config || {});
|
||||
setHasUnsavedChanges(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load hooks configuration:", err);
|
||||
setLoadError(err instanceof Error ? err.message : "Failed to load hooks configuration");
|
||||
setHooks({});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
// No projectPath for project/local scopes
|
||||
setHooks({});
|
||||
}
|
||||
}, [projectPath, scope]);
|
||||
|
||||
// Reset initial mount flag when hooks prop changes
|
||||
useEffect(() => {
|
||||
isInitialMount.current = true;
|
||||
setHasUnsavedChanges(false); // Reset unsaved changes when hooks prop changes
|
||||
|
||||
// Reinitialize editable hooks when hooks prop changes
|
||||
const result = {
|
||||
PreToolUse: [],
|
||||
PostToolUse: [],
|
||||
Notification: [],
|
||||
Stop: [],
|
||||
SubagentStop: []
|
||||
} as any;
|
||||
|
||||
// Initialize matcher events
|
||||
matcherEvents.forEach(event => {
|
||||
const matchers = hooks?.[event] as HookMatcher[] | undefined;
|
||||
if (matchers && Array.isArray(matchers)) {
|
||||
result[event] = matchers.map(matcher => ({
|
||||
...matcher,
|
||||
id: HooksManager.generateId(),
|
||||
expanded: false,
|
||||
hooks: (matcher.hooks || []).map(hook => ({
|
||||
...hook,
|
||||
id: HooksManager.generateId()
|
||||
}))
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize direct events
|
||||
directEvents.forEach(event => {
|
||||
const commands = hooks?.[event] as HookCommand[] | undefined;
|
||||
if (commands && Array.isArray(commands)) {
|
||||
result[event] = commands.map(hook => ({
|
||||
...hook,
|
||||
id: HooksManager.generateId()
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
setEditableHooks(result);
|
||||
}, [hooks]);
|
||||
|
||||
// Track changes when editable hooks change (but don't save automatically)
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
}, [editableHooks]);
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
const getHooks = () => {
|
||||
const newHooks: HooksConfiguration = {};
|
||||
|
||||
// Handle matcher events
|
||||
matcherEvents.forEach(event => {
|
||||
const matchers = editableHooks[event];
|
||||
if (matchers.length > 0) {
|
||||
newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({
|
||||
...matcher,
|
||||
hooks: matcher.hooks.map(({ id, ...hook }) => hook)
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle direct events
|
||||
directEvents.forEach(event => {
|
||||
const commands = editableHooks[event];
|
||||
if (commands.length > 0) {
|
||||
newHooks[event] = commands.map(({ id, ...hook }) => hook);
|
||||
}
|
||||
});
|
||||
|
||||
return newHooks;
|
||||
};
|
||||
|
||||
onChange(hasUnsavedChanges, getHooks);
|
||||
}
|
||||
}, [hasUnsavedChanges, editableHooks, onChange]);
|
||||
|
||||
// Save function to be called explicitly
|
||||
const handleSave = async () => {
|
||||
if (scope !== 'user' && !projectPath) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
const newHooks: HooksConfiguration = {};
|
||||
|
||||
// Handle matcher events
|
||||
matcherEvents.forEach(event => {
|
||||
const matchers = editableHooks[event];
|
||||
if (matchers.length > 0) {
|
||||
newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({
|
||||
...matcher,
|
||||
hooks: matcher.hooks.map(({ id, ...hook }) => hook)
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle direct events
|
||||
directEvents.forEach(event => {
|
||||
const commands = editableHooks[event];
|
||||
if (commands.length > 0) {
|
||||
newHooks[event] = commands.map(({ id, ...hook }) => hook);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await api.updateHooksConfig(scope, newHooks, projectPath);
|
||||
setHooks(newHooks);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save hooks:', error);
|
||||
setLoadError(error instanceof Error ? error.message : 'Failed to save hooks');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addMatcher = (event: HookEvent) => {
|
||||
// Only for events with matchers
|
||||
if (!matcherEvents.includes(event as any)) return;
|
||||
|
||||
const newMatcher: EditableHookMatcher = {
|
||||
id: HooksManager.generateId(),
|
||||
matcher: '',
|
||||
hooks: [],
|
||||
expanded: true
|
||||
};
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: [...(prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher]
|
||||
}));
|
||||
};
|
||||
|
||||
const addDirectCommand = (event: HookEvent) => {
|
||||
// Only for events without matchers
|
||||
if (!directEvents.includes(event as any)) return;
|
||||
|
||||
const newCommand: EditableHookCommand = {
|
||||
id: HooksManager.generateId(),
|
||||
type: 'command',
|
||||
command: ''
|
||||
};
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: [...(prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), newCommand]
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMatcher = (event: HookEvent, matcherId: string, updates: Partial<EditableHookMatcher>) => {
|
||||
if (!matcherEvents.includes(event as any)) return;
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||
matcher.id === matcherId ? { ...matcher, ...updates } : matcher
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const removeMatcher = (event: HookEvent, matcherId: string) => {
|
||||
if (!matcherEvents.includes(event as any)) return;
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).filter(matcher => matcher.id !== matcherId)
|
||||
}));
|
||||
};
|
||||
|
||||
const updateDirectCommand = (event: HookEvent, commandId: string, updates: Partial<EditableHookCommand>) => {
|
||||
if (!directEvents.includes(event as any)) return;
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, ...updates } : cmd
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const removeDirectCommand = (event: HookEvent, commandId: string) => {
|
||||
if (!directEvents.includes(event as any)) return;
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).filter(cmd => cmd.id !== commandId)
|
||||
}));
|
||||
};
|
||||
|
||||
const applyTemplate = (template: HookTemplate) => {
|
||||
if (matcherEvents.includes(template.event as any)) {
|
||||
// For events with matchers
|
||||
const newMatcher: EditableHookMatcher = {
|
||||
id: HooksManager.generateId(),
|
||||
matcher: template.matcher,
|
||||
hooks: template.commands.map(cmd => ({
|
||||
id: HooksManager.generateId(),
|
||||
type: 'command' as const,
|
||||
command: cmd
|
||||
})),
|
||||
expanded: true
|
||||
};
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[template.event]: [...(prev[template.event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher]
|
||||
}));
|
||||
} else {
|
||||
// For direct events
|
||||
const newCommands: EditableHookCommand[] = template.commands.map(cmd => ({
|
||||
id: HooksManager.generateId(),
|
||||
type: 'command' as const,
|
||||
command: cmd
|
||||
}));
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[template.event]: [...(prev[template.event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), ...newCommands]
|
||||
}));
|
||||
}
|
||||
|
||||
setSelectedEvent(template.event);
|
||||
setShowTemplateDialog(false);
|
||||
};
|
||||
|
||||
const validateHooks = async () => {
|
||||
if (!hooks) {
|
||||
setValidationErrors([]);
|
||||
setValidationWarnings([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await HooksManager.validateConfig(hooks);
|
||||
setValidationErrors(result.errors.map(e => e.message));
|
||||
setValidationWarnings(result.warnings.map(w => `${w.message} in command: ${(w.command || '').substring(0, 50)}...`));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateHooks();
|
||||
}, [hooks]);
|
||||
|
||||
const addCommand = (event: HookEvent, matcherId: string) => {
|
||||
if (!matcherEvents.includes(event as any)) return;
|
||||
|
||||
const newCommand: EditableHookCommand = {
|
||||
id: HooksManager.generateId(),
|
||||
type: 'command',
|
||||
command: ''
|
||||
};
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||
matcher.id === matcherId
|
||||
? { ...matcher, hooks: [...matcher.hooks, newCommand] }
|
||||
: matcher
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCommand = (
|
||||
event: HookEvent,
|
||||
matcherId: string,
|
||||
commandId: string,
|
||||
updates: Partial<EditableHookCommand>
|
||||
) => {
|
||||
if (!matcherEvents.includes(event as any)) return;
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||
matcher.id === matcherId
|
||||
? {
|
||||
...matcher,
|
||||
hooks: matcher.hooks.map(cmd =>
|
||||
cmd.id === commandId ? { ...cmd, ...updates } : cmd
|
||||
)
|
||||
}
|
||||
: matcher
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const removeCommand = (event: HookEvent, matcherId: string, commandId: string) => {
|
||||
if (!matcherEvents.includes(event as any)) return;
|
||||
|
||||
setEditableHooks(prev => ({
|
||||
...prev,
|
||||
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||
matcher.id === matcherId
|
||||
? { ...matcher, hooks: matcher.hooks.filter(cmd => cmd.id !== commandId) }
|
||||
: matcher
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const renderMatcher = (event: HookEvent, matcher: EditableHookMatcher) => (
|
||||
<Card key={matcher.id} className="p-4 space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-0 h-6 w-6"
|
||||
onClick={() => updateMatcher(event, matcher.id, { expanded: !matcher.expanded })}
|
||||
>
|
||||
{matcher.expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`matcher-${matcher.id}`}>Pattern</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tool name pattern (regex supported). Leave empty to match all tools.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`matcher-${matcher.id}`}
|
||||
placeholder="e.g., Bash, Edit|Write, mcp__.*"
|
||||
value={matcher.matcher || ''}
|
||||
onChange={(e) => updateMatcher(event, matcher.id, { matcher: e.target.value })}
|
||||
disabled={readOnly}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={matcher.matcher || 'custom'}
|
||||
onValueChange={(value) => {
|
||||
if (value !== 'custom') {
|
||||
updateMatcher(event, matcher.id, { matcher: value });
|
||||
}
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Common patterns" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
{COMMON_TOOL_MATCHERS.map(pattern => (
|
||||
<SelectItem key={pattern} value={pattern}>{pattern}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMatcher(event, matcher.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{matcher.expanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 pl-10"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Commands</Label>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addCommand(event, matcher.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Command
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{matcher.hooks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No commands added yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{matcher.hooks.map((hook) => (
|
||||
<div key={hook.id} className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Textarea
|
||||
placeholder="Enter shell command..."
|
||||
value={hook.command || ''}
|
||||
onChange={(e) => updateCommand(event, matcher.id, hook.id, { command: e.target.value })}
|
||||
disabled={readOnly}
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="60"
|
||||
value={hook.timeout || ''}
|
||||
onChange={(e) => updateCommand(event, matcher.id, hook.id, {
|
||||
timeout: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
disabled={readOnly}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeCommand(event, matcher.id, hook.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show warnings for this command */}
|
||||
{(() => {
|
||||
const warnings = HooksManager.checkDangerousPatterns(hook.command || '');
|
||||
return warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-2 bg-yellow-500/10 rounded-md">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
{warnings.map((warning, i) => (
|
||||
<p key={i} className="text-xs text-yellow-600">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderDirectCommand = (event: HookEvent, command: EditableHookCommand) => (
|
||||
<Card key={command.id} className="p-4 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Textarea
|
||||
placeholder="Enter shell command..."
|
||||
value={command.command || ''}
|
||||
onChange={(e) => updateDirectCommand(event, command.id, { command: e.target.value })}
|
||||
disabled={readOnly}
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="60"
|
||||
value={command.timeout || ''}
|
||||
onChange={(e) => updateDirectCommand(event, command.id, {
|
||||
timeout: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
disabled={readOnly}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDirectCommand(event, command.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show warnings for this command */}
|
||||
{(() => {
|
||||
const warnings = HooksManager.checkDangerousPatterns(command.command || '');
|
||||
return warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-2 bg-yellow-500/10 rounded-md">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
{warnings.map((warning, i) => (
|
||||
<p key={i} className="text-xs text-yellow-600">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Loading hooks configuration...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{loadError && !isLoading && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Hooks Configuration</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={scope === 'project' ? 'secondary' : scope === 'local' ? 'outline' : 'default'}>
|
||||
{scope === 'project' ? 'Project' : scope === 'local' ? 'Local' : 'User'} Scope
|
||||
</Badge>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowTemplateDialog(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Templates
|
||||
</Button>
|
||||
{!hideActions && (
|
||||
<Button
|
||||
variant={hasUnsavedChanges ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasUnsavedChanges || isSaving || !projectPath}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure shell commands to execute at various points in Claude Code's lifecycle.
|
||||
{scope === 'local' && ' These settings are not committed to version control.'}
|
||||
</p>
|
||||
{hasUnsavedChanges && !readOnly && (
|
||||
<p className="text-sm text-amber-600">
|
||||
You have unsaved changes. Click Save to persist them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation Messages */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="p-3 bg-red-500/10 rounded-md space-y-1">
|
||||
<p className="text-sm font-medium text-red-600">Validation Errors:</p>
|
||||
{validationErrors.map((error, i) => (
|
||||
<p key={i} className="text-xs text-red-600">• {error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationWarnings.length > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 rounded-md space-y-1">
|
||||
<p className="text-sm font-medium text-yellow-600">Security Warnings:</p>
|
||||
{validationWarnings.map((warning, i) => (
|
||||
<p key={i} className="text-xs text-yellow-600">• {warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Tabs */}
|
||||
<Tabs value={selectedEvent} onValueChange={(v) => setSelectedEvent(v as HookEvent)}>
|
||||
<TabsList className="w-full">
|
||||
{(Object.keys(EVENT_INFO) as HookEvent[]).map(event => {
|
||||
const isMatcherEvent = matcherEvents.includes(event as any);
|
||||
const count = isMatcherEvent
|
||||
? (editableHooks[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).length
|
||||
: (editableHooks[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).length;
|
||||
|
||||
return (
|
||||
<TabsTrigger key={event} value={event} className="flex items-center gap-2">
|
||||
{EVENT_INFO[event].icon}
|
||||
<span className="hidden sm:inline">{EVENT_INFO[event].label}</span>
|
||||
{count > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{(Object.keys(EVENT_INFO) as HookEvent[]).map(event => {
|
||||
const isMatcherEvent = matcherEvents.includes(event as any);
|
||||
const items = isMatcherEvent
|
||||
? (editableHooks[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[])
|
||||
: (editableHooks[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]);
|
||||
|
||||
return (
|
||||
<TabsContent key={event} value={event} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{EVENT_INFO[event].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">No hooks configured for this event</p>
|
||||
{!readOnly && (
|
||||
<Button onClick={() => isMatcherEvent ? addMatcher(event) : addDirectCommand(event)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Hook
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isMatcherEvent
|
||||
? (items as EditableHookMatcher[]).map(matcher => renderMatcher(event, matcher))
|
||||
: (items as EditableHookCommand[]).map(command => renderDirectCommand(event, command))
|
||||
}
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => isMatcherEvent ? addMatcher(event) : addDirectCommand(event)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Another {isMatcherEvent ? 'Matcher' : 'Command'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
|
||||
{/* Template Dialog */}
|
||||
<Dialog open={showTemplateDialog} onOpenChange={setShowTemplateDialog}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Hook Templates</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a pre-configured hook template to get started quickly
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{HOOK_TEMPLATES.map(template => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="p-4 cursor-pointer hover:bg-accent"
|
||||
onClick={() => applyTemplate(template)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{template.name}</h4>
|
||||
<Badge>{EVENT_INFO[template.event].label}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||
{matcherEvents.includes(template.event as any) && template.matcher && (
|
||||
<p className="text-xs font-mono bg-muted px-2 py-1 rounded inline-block">
|
||||
Matcher: {template.matcher}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,11 +1,26 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { FolderOpen, ChevronRight, Clock } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatUnixTimestamp } from "@/lib/date-utils";
|
||||
import {
|
||||
FolderOpen,
|
||||
Calendar,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
MoreVertical
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Project } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeAgo } from "@/lib/date-utils";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface ProjectListProps {
|
||||
/**
|
||||
@@ -16,13 +31,29 @@ interface ProjectListProps {
|
||||
* Callback when a project is clicked
|
||||
*/
|
||||
onProjectClick: (project: Project) => void;
|
||||
/**
|
||||
* Callback when hooks configuration is clicked
|
||||
*/
|
||||
onProjectSettings?: (project: Project) => void;
|
||||
/**
|
||||
* Whether the list is currently loading
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
/**
|
||||
* Extracts the project name from the full path
|
||||
*/
|
||||
const getProjectName = (path: string): string => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
/**
|
||||
* ProjectList component - Displays a paginated list of projects with hover animations
|
||||
@@ -36,6 +67,7 @@ const ITEMS_PER_PAGE = 5;
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({
|
||||
projects,
|
||||
onProjectClick,
|
||||
onProjectSettings,
|
||||
className,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -66,27 +98,63 @@ export const ProjectList: React.FC<ProjectListProps> = ({
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
|
||||
className="p-4 hover:shadow-md transition-all duration-200 cursor-pointer group"
|
||||
onClick={() => onProjectClick(project)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{project.path}</p>
|
||||
<div className="flex items-center space-x-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FolderOpen className="h-5 w-5 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-base truncate">
|
||||
{getProjectName(project.path)}
|
||||
</h3>
|
||||
{project.sessions.length > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{project.sessions.length} session{project.sessions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatUnixTimestamp(project.created_at)}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-3 font-mono truncate">
|
||||
{project.path}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatTimeAgo(project.created_at * 1000)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>{project.sessions.length} conversations</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</CardContent>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onProjectSettings && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProjectSettings(project);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Hooks
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -99,4 +167,4 @@ export const ProjectList: React.FC<ProjectListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
192
src/components/ProjectSettings.tsx
Normal file
192
src/components/ProjectSettings.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* ProjectSettings component for managing project-specific hooks configuration
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HooksEditor } from '@/components/HooksEditor';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Toast, ToastContainer } from '@/components/ui/toast';
|
||||
import type { Project } from '@/lib/api';
|
||||
|
||||
interface ProjectSettingsProps {
|
||||
project: Project;
|
||||
onBack: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
|
||||
project,
|
||||
onBack,
|
||||
className
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('project');
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
// Other hooks settings
|
||||
const [gitIgnoreLocal, setGitIgnoreLocal] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkGitIgnore();
|
||||
}, [project]);
|
||||
|
||||
const checkGitIgnore = async () => {
|
||||
try {
|
||||
// Check if .claude/settings.local.json is in .gitignore
|
||||
const gitignorePath = `${project.path}/.gitignore`;
|
||||
const gitignoreContent = await api.readClaudeMdFile(gitignorePath);
|
||||
setGitIgnoreLocal(gitignoreContent.includes('.claude/settings.local.json'));
|
||||
} catch {
|
||||
// .gitignore might not exist
|
||||
setGitIgnoreLocal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addToGitIgnore = async () => {
|
||||
try {
|
||||
const gitignorePath = `${project.path}/.gitignore`;
|
||||
let content = '';
|
||||
|
||||
try {
|
||||
content = await api.readClaudeMdFile(gitignorePath);
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
}
|
||||
|
||||
if (!content.includes('.claude/settings.local.json')) {
|
||||
content += '\n# Claude local settings (machine-specific)\n.claude/settings.local.json\n';
|
||||
await api.saveClaudeMdFile(gitignorePath, content);
|
||||
setGitIgnoreLocal(true);
|
||||
setToast({ message: 'Added to .gitignore', type: 'success' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update .gitignore:', err);
|
||||
setToast({ message: 'Failed to update .gitignore', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold">Hooks</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="font-mono">{project.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="project" className="gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Project Hooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="local" className="gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Local Hooks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="project" className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Project Hooks</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
These hooks apply to all users working on this project. They are stored in
|
||||
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/settings.json</code>
|
||||
and should be committed to version control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HooksEditor
|
||||
projectPath={project.path}
|
||||
scope="project"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Local Hooks</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
These hooks only apply to your machine. They are stored in
|
||||
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/settings.local.json</code>
|
||||
and should NOT be committed to version control.
|
||||
</p>
|
||||
|
||||
{!gitIgnoreLocal && (
|
||||
<div className="flex items-center gap-4 p-3 bg-yellow-500/10 rounded-md">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-yellow-600">
|
||||
Local settings file is not in .gitignore
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addToGitIgnore}
|
||||
>
|
||||
Add to .gitignore
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HooksEditor
|
||||
projectPath={project.path}
|
||||
scope="local"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Container */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -6,12 +6,7 @@ import {
|
||||
Trash2,
|
||||
Save,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Code,
|
||||
Settings2,
|
||||
Terminal,
|
||||
Loader2,
|
||||
Database
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -28,6 +23,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||
import { StorageTab } from "./StorageTab";
|
||||
import { HooksEditor } from "./HooksEditor";
|
||||
|
||||
interface SettingsProps {
|
||||
/**
|
||||
@@ -59,26 +55,27 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [settings, setSettings] = useState<ClaudeSettings>({});
|
||||
const [settings, setSettings] = useState<ClaudeSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
// Permission rules state
|
||||
const [allowRules, setAllowRules] = useState<PermissionRule[]>([]);
|
||||
const [denyRules, setDenyRules] = useState<PermissionRule[]>([]);
|
||||
|
||||
// Environment variables state
|
||||
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||
|
||||
// Claude binary path state
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [currentBinaryPath, setCurrentBinaryPath] = useState<string | null>(null);
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
||||
const [binaryPathChanged, setBinaryPathChanged] = useState(false);
|
||||
|
||||
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
// Permission rules state
|
||||
const [allowRules, setAllowRules] = useState<PermissionRule[]>([]);
|
||||
const [denyRules, setDenyRules] = useState<PermissionRule[]>([]);
|
||||
|
||||
// Environment variables state
|
||||
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||
|
||||
// Hooks state
|
||||
const [userHooksChanged, setUserHooksChanged] = useState(false);
|
||||
const getUserHooks = React.useRef<(() => any) | null>(null);
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -154,7 +151,6 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Saves the current settings
|
||||
*/
|
||||
@@ -189,6 +185,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
setBinaryPathChanged(false);
|
||||
}
|
||||
|
||||
// Save user hooks if changed
|
||||
if (userHooksChanged && getUserHooks.current) {
|
||||
const hooks = getUserHooks.current();
|
||||
await api.updateHooksConfig('user', hooks);
|
||||
setUserHooksChanged(false);
|
||||
}
|
||||
|
||||
setToast({ message: "Settings saved successfully!", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("Failed to save settings:", err);
|
||||
@@ -353,28 +356,14 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<Settings2 className="h-4 w-4 text-slate-500" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="permissions" className="gap-2">
|
||||
<Shield className="h-4 w-4 text-amber-500" />
|
||||
Permissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="environment" className="gap-2">
|
||||
<Terminal className="h-4 w-4 text-blue-500" />
|
||||
Environment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="gap-2">
|
||||
<Code className="h-4 w-4 text-purple-500" />
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="storage" className="gap-2">
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-6">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="permissions">Permissions</TabsTrigger>
|
||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
<TabsTrigger value="hooks">Hooks</TabsTrigger>
|
||||
<TabsTrigger value="storage">Storage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General Settings */}
|
||||
@@ -690,6 +679,32 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Hooks Settings */}
|
||||
<TabsContent value="hooks" className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-2">User Hooks</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure hooks that apply to all Claude Code sessions for your user account.
|
||||
These are stored in <code className="mx-1 px-2 py-1 bg-muted rounded text-xs">~/.claude/settings.json</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HooksEditor
|
||||
key={activeTab}
|
||||
scope="user"
|
||||
className="border-0"
|
||||
hideActions={true}
|
||||
onChange={(hasChanges, getHooks) => {
|
||||
setUserHooksChanged(hasChanges);
|
||||
getUserHooks.current = getHooks;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Storage Tab */}
|
||||
<TabsContent value="storage">
|
||||
<StorageTab />
|
||||
|
Reference in New Issue
Block a user