mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat(a2ui): Implement A2UI backend with question handling and WebSocket support
- Added A2UITypes for defining question structures and answers. - Created A2UIWebSocketHandler for managing WebSocket connections and message handling. - Developed ask-question tool for interactive user questions via A2UI. - Introduced platformUtils for platform detection and shell command handling. - Centralized TypeScript types in index.ts for better organization. - Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
194
ccw/frontend/src/components/hook/EventGroup.tsx
Normal file
194
ccw/frontend/src/components/hook/EventGroup.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
// ========================================
|
||||
// Event Group Component
|
||||
// ========================================
|
||||
// Groups hooks by trigger event type
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Zap,
|
||||
Wrench,
|
||||
CheckCircle,
|
||||
StopCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { HookCard, type HookCardData, type HookTriggerType } from './HookCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface EventGroupProps {
|
||||
eventType: HookTriggerType;
|
||||
hooks: HookCardData[];
|
||||
onHookToggle: (hookName: string, enabled: boolean) => void;
|
||||
onHookEdit: (hook: HookCardData) => void;
|
||||
onHookDelete: (hookName: string) => void;
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getEventIcon(eventType: HookTriggerType) {
|
||||
switch (eventType) {
|
||||
case 'UserPromptSubmit':
|
||||
return Zap;
|
||||
case 'PreToolUse':
|
||||
return Wrench;
|
||||
case 'PostToolUse':
|
||||
return CheckCircle;
|
||||
case 'Stop':
|
||||
return StopCircle;
|
||||
}
|
||||
}
|
||||
|
||||
function getEventColor(eventType: HookTriggerType): string {
|
||||
switch (eventType) {
|
||||
case 'UserPromptSubmit':
|
||||
return 'text-amber-500 bg-amber-500/10';
|
||||
case 'PreToolUse':
|
||||
return 'text-blue-500 bg-blue-500/10';
|
||||
case 'PostToolUse':
|
||||
return 'text-green-500 bg-green-500/10';
|
||||
case 'Stop':
|
||||
return 'text-red-500 bg-red-500/10';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function EventGroup({
|
||||
eventType,
|
||||
hooks,
|
||||
onHookToggle,
|
||||
onHookEdit,
|
||||
onHookDelete,
|
||||
}: EventGroupProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [expandedHooks, setExpandedHooks] = useState<Set<string>>(new Set());
|
||||
|
||||
const Icon = getEventIcon(eventType);
|
||||
const iconColorClass = getEventColor(eventType);
|
||||
|
||||
const enabledCount = hooks.filter((h) => h.enabled).length;
|
||||
const totalCount = hooks.length;
|
||||
|
||||
const handleToggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const handleToggleHookExpand = (hookName: string) => {
|
||||
setExpandedHooks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hookName)) {
|
||||
next.delete(hookName);
|
||||
} else {
|
||||
next.add(hookName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpandAll = () => {
|
||||
setExpandedHooks(new Set(hooks.map((h) => h.name)));
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedHooks(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Event Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors border-b border-border"
|
||||
onClick={handleToggleExpand}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-2 rounded-lg', iconColorClass)}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{formatMessage({ id: `cliHooks.trigger.${eventType}` })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.stats.count' }, {
|
||||
enabled: enabledCount,
|
||||
total: totalCount
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{totalCount}
|
||||
</Badge>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hooks List */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3 bg-muted/10">
|
||||
{totalCount === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">{formatMessage({ id: 'cliHooks.empty.noHooksInEvent' })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Expand/Collapse All */}
|
||||
{totalCount > 1 && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleExpandAll}
|
||||
>
|
||||
{formatMessage({ id: 'cliHooks.actions.expandAll' })}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">/</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleCollapseAll}
|
||||
>
|
||||
{formatMessage({ id: 'cliHooks.actions.collapseAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hook Cards */}
|
||||
<div className="space-y-2">
|
||||
{hooks.map((hook) => (
|
||||
<HookCard
|
||||
key={hook.name}
|
||||
hook={hook}
|
||||
isExpanded={expandedHooks.has(hook.name)}
|
||||
onToggleExpand={() => handleToggleHookExpand(hook.name)}
|
||||
onToggle={onHookToggle}
|
||||
onEdit={onHookEdit}
|
||||
onDelete={onHookDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventGroup;
|
||||
238
ccw/frontend/src/components/hook/HookCard.tsx
Normal file
238
ccw/frontend/src/components/hook/HookCard.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// ========================================
|
||||
// Hook Card Component
|
||||
// ========================================
|
||||
// Individual hook display card with actions
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
GitFork,
|
||||
Power,
|
||||
PowerOff,
|
||||
Edit,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type HookTriggerType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||
|
||||
export interface HookCardData {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
trigger: HookTriggerType;
|
||||
matcher?: string;
|
||||
command?: string;
|
||||
script?: string;
|
||||
}
|
||||
|
||||
export interface HookCardProps {
|
||||
hook: HookCardData;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggle: (hookName: string, enabled: boolean) => void;
|
||||
onEdit: (hook: HookCardData) => void;
|
||||
onDelete: (hookName: string) => void;
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getTriggerIcon(trigger: HookTriggerType) {
|
||||
switch (trigger) {
|
||||
case 'UserPromptSubmit':
|
||||
return '⚡';
|
||||
case 'PreToolUse':
|
||||
return '🔧';
|
||||
case 'PostToolUse':
|
||||
return '✅';
|
||||
case 'Stop':
|
||||
return '🛑';
|
||||
default:
|
||||
return '📌';
|
||||
}
|
||||
}
|
||||
|
||||
function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' {
|
||||
switch (trigger) {
|
||||
case 'UserPromptSubmit':
|
||||
return 'default';
|
||||
case 'PreToolUse':
|
||||
return 'secondary';
|
||||
case 'PostToolUse':
|
||||
return 'outline';
|
||||
case 'Stop':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function HookCard({
|
||||
hook,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: HookCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleToggle = () => {
|
||||
onToggle(hook.name, !hook.enabled);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
onEdit(hook);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(formatMessage({ id: 'cliHooks.actions.deleteConfirm' }, { hookName: hook.name }))) {
|
||||
onDelete(hook.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !hook.enabled && 'opacity-60')}>
|
||||
{/* Header */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg flex-shrink-0',
|
||||
hook.enabled ? 'bg-primary/10' : 'bg-muted'
|
||||
)}>
|
||||
<GitFork className={cn(
|
||||
'w-4 h-4',
|
||||
hook.enabled ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{hook.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={getTriggerVariant(hook.trigger)}
|
||||
className="text-xs flex-shrink-0"
|
||||
>
|
||||
<span className="mr-1">{getTriggerIcon(hook.trigger)}</span>
|
||||
{formatMessage({ id: `cliHooks.trigger.${hook.trigger}` })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={hook.enabled ? 'default' : 'secondary'}
|
||||
className="text-xs flex-shrink-0"
|
||||
>
|
||||
{hook.enabled
|
||||
? formatMessage({ id: 'common.status.enabled' })
|
||||
: formatMessage({ id: 'common.status.disabled' })
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
{hook.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{hook.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleToggle}
|
||||
title={hook.enabled
|
||||
? formatMessage({ id: 'cliHooks.actions.disable' })
|
||||
: formatMessage({ id: 'cliHooks.actions.enable' })
|
||||
}
|
||||
>
|
||||
{hook.enabled ? (
|
||||
<Power className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<PowerOff className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleEdit}
|
||||
title={formatMessage({ id: 'common.actions.edit' })}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={handleDelete}
|
||||
title={formatMessage({ id: 'common.actions.delete' })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={onToggleExpand}
|
||||
title={isExpanded
|
||||
? formatMessage({ id: 'cliHooks.actions.collapse' })
|
||||
: formatMessage({ id: 'cliHooks.actions.expand' })
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border bg-muted/30 p-4 space-y-3">
|
||||
{hook.description && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.description' })}
|
||||
</label>
|
||||
<p className="text-sm text-foreground mt-1">{hook.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.matcher' })}
|
||||
</label>
|
||||
<p className="text-sm text-foreground mt-1 font-mono bg-muted px-2 py-1 rounded">
|
||||
{hook.matcher || formatMessage({ id: 'cliHooks.allTools' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.command' })}
|
||||
</label>
|
||||
<p className="text-sm text-foreground mt-1 font-mono bg-muted px-2 py-1 rounded break-all max-h-32 overflow-y-auto">
|
||||
{hook.command || hook.script || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default HookCard;
|
||||
289
ccw/frontend/src/components/hook/HookFormDialog.tsx
Normal file
289
ccw/frontend/src/components/hook/HookFormDialog.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
// ========================================
|
||||
// Hook Form Dialog Component
|
||||
// ========================================
|
||||
// Dialog for creating and editing hooks
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import type { HookCardData, HookTriggerType } from './HookCard';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type HookFormMode = 'create' | 'edit';
|
||||
|
||||
export interface HookFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: HookTriggerType;
|
||||
matcher: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface HookFormDialogProps {
|
||||
mode: HookFormMode;
|
||||
hook?: HookCardData;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: HookFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
// ========== Helper: Form Validation ==========
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
trigger?: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
function validateForm(data: HookFormData): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!data.name.trim()) {
|
||||
errors.name = 'validation.nameRequired';
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) {
|
||||
errors.name = 'validation.nameInvalid';
|
||||
}
|
||||
|
||||
if (!data.trigger) {
|
||||
errors.trigger = 'validation.triggerRequired';
|
||||
}
|
||||
|
||||
if (!data.command.trim()) {
|
||||
errors.command = 'validation.commandRequired';
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function HookFormDialog({
|
||||
mode,
|
||||
hook,
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
}: HookFormDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [formData, setFormData] = useState<HookFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: '',
|
||||
command: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form when dialog opens or hook changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === 'edit' && hook) {
|
||||
setFormData({
|
||||
name: hook.name,
|
||||
description: hook.description || '',
|
||||
trigger: hook.trigger,
|
||||
matcher: hook.matcher || '',
|
||||
command: hook.command || hook.script || '',
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: '',
|
||||
command: '',
|
||||
});
|
||||
}
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, mode, hook]);
|
||||
|
||||
const handleFieldChange = (
|
||||
field: keyof HookFormData,
|
||||
value: string
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear error for this field when user starts typing
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate form
|
||||
const validationErrors = validateForm(formData);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save hook:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [
|
||||
{ value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' },
|
||||
{ value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' },
|
||||
{ value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' },
|
||||
{ value: 'Stop', label: 'cliHooks.trigger.Stop' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'create'
|
||||
? formatMessage({ id: 'cliHooks.dialog.createTitle' })
|
||||
: formatMessage({ id: 'cliHooks.dialog.editTitle' }, { hookName: hook?.name })
|
||||
}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="hook-name" className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.name' })} *
|
||||
</label>
|
||||
<Input
|
||||
id="hook-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'cliHooks.form.namePlaceholder' })}
|
||||
className="mt-1"
|
||||
error={!!errors.name}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{formatMessage({ id: `cliHooks.${errors.name}` })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="hook-description" className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.description' })}
|
||||
</label>
|
||||
<Textarea
|
||||
id="hook-description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFieldChange('description', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'cliHooks.form.descriptionPlaceholder' })}
|
||||
className="mt-1"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trigger */}
|
||||
<div>
|
||||
<label htmlFor="hook-trigger" className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.trigger' })} *
|
||||
</label>
|
||||
<Select
|
||||
value={formData.trigger}
|
||||
onValueChange={(value) => handleFieldChange('trigger', value as HookTriggerType)}
|
||||
>
|
||||
<SelectTrigger className="mt-1" id="hook-trigger">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{formatMessage({ id: option.label })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.trigger && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{formatMessage({ id: `cliHooks.${errors.trigger}` })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matcher */}
|
||||
<div>
|
||||
<label htmlFor="hook-matcher" className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.matcher' })}
|
||||
</label>
|
||||
<Input
|
||||
id="hook-matcher"
|
||||
value={formData.matcher}
|
||||
onChange={(e) => handleFieldChange('matcher', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'cliHooks.form.matcherPlaceholder' })}
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'cliHooks.form.matcherHelp' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div>
|
||||
<label htmlFor="hook-command" className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.form.command' })} *
|
||||
</label>
|
||||
<Textarea
|
||||
id="hook-command"
|
||||
value={formData.command}
|
||||
onChange={(e) => handleFieldChange('command', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'cliHooks.form.commandPlaceholder' })}
|
||||
className="mt-1 font-mono text-sm"
|
||||
rows={4}
|
||||
error={!!errors.command}
|
||||
/>
|
||||
{errors.command && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{formatMessage({ id: `cliHooks.${errors.command}` })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'cliHooks.form.commandHelp' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? formatMessage({ id: 'common.actions.saving' })
|
||||
: formatMessage({ id: 'common.actions.save' })
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default HookFormDialog;
|
||||
268
ccw/frontend/src/components/hook/HookQuickTemplates.tsx
Normal file
268
ccw/frontend/src/components/hook/HookQuickTemplates.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
// ========================================
|
||||
// Hook Quick Templates Component
|
||||
// ========================================
|
||||
// Predefined hook templates for quick installation
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Bell,
|
||||
Database,
|
||||
Wrench,
|
||||
Check,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* Template category type
|
||||
*/
|
||||
export type TemplateCategory = 'notification' | 'indexing' | 'automation';
|
||||
|
||||
/**
|
||||
* Hook template definition
|
||||
*/
|
||||
export interface HookTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: TemplateCategory;
|
||||
trigger: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
|
||||
command: string;
|
||||
args?: string[];
|
||||
matcher?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component props
|
||||
*/
|
||||
export interface HookQuickTemplatesProps {
|
||||
/** Callback when install button is clicked */
|
||||
onInstallTemplate: (templateId: string) => Promise<void>;
|
||||
/** Array of installed template IDs */
|
||||
installedTemplates: string[];
|
||||
/** Optional loading state */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// ========== Hook Templates ==========
|
||||
|
||||
/**
|
||||
* Predefined hook templates for quick installation
|
||||
*/
|
||||
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
{
|
||||
id: 'ccw-notify',
|
||||
name: 'CCW Dashboard Notify',
|
||||
description: 'Send notifications to CCW dashboard when files are written',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'codexlens-update',
|
||||
name: 'CodexLens Auto-Update',
|
||||
description: 'Update CodexLens index when files are written or edited',
|
||||
category: 'indexing',
|
||||
trigger: 'Stop',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'git-add',
|
||||
name: 'Auto Git Stage',
|
||||
description: 'Automatically stage written files to git',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'lint-check',
|
||||
name: 'Auto ESLint',
|
||||
description: 'Run ESLint on JavaScript/TypeScript files after write',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'log-tool',
|
||||
name: 'Tool Usage Logger',
|
||||
description: 'Log all tool executions to a file for audit trail',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"'
|
||||
]
|
||||
}
|
||||
] as const;
|
||||
|
||||
// ========== Category Icons ==========
|
||||
|
||||
const CATEGORY_ICONS: Record<TemplateCategory, { icon: typeof Bell; color: string }> = {
|
||||
notification: { icon: Bell, color: 'text-blue-500' },
|
||||
indexing: { icon: Database, color: 'text-purple-500' },
|
||||
automation: { icon: Wrench, color: 'text-orange-500' }
|
||||
};
|
||||
|
||||
// ========== Category Names ==========
|
||||
|
||||
function getCategoryName(category: TemplateCategory, formatMessage: ReturnType<typeof useIntl>['formatMessage']): string {
|
||||
const names: Record<TemplateCategory, string> = {
|
||||
notification: formatMessage({ id: 'cliHooks.templates.categories.notification' }),
|
||||
indexing: formatMessage({ id: 'cliHooks.templates.categories.indexing' }),
|
||||
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' })
|
||||
};
|
||||
return names[category];
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* HookQuickTemplates - Display predefined hook templates for quick installation
|
||||
*/
|
||||
export function HookQuickTemplates({
|
||||
onInstallTemplate,
|
||||
installedTemplates,
|
||||
isLoading = false
|
||||
}: HookQuickTemplatesProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Group templates by category
|
||||
const templatesByCategory = useMemo(() => {
|
||||
return HOOK_TEMPLATES.reduce((acc, template) => {
|
||||
if (!acc[template.category]) {
|
||||
acc[template.category] = [];
|
||||
}
|
||||
acc[template.category].push(template);
|
||||
return acc;
|
||||
}, {} as Record<TemplateCategory, HookTemplate[]>);
|
||||
}, []);
|
||||
|
||||
// Define category order
|
||||
const categoryOrder: TemplateCategory[] = ['notification', 'indexing', 'automation'];
|
||||
|
||||
const handleInstall = async (templateId: string) => {
|
||||
await onInstallTemplate(templateId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.templates.title' })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.templates.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{categoryOrder.map((category) => {
|
||||
const templates = templatesByCategory[category];
|
||||
if (!templates || templates.length === 0) return null;
|
||||
|
||||
const { icon: CategoryIcon, color } = CATEGORY_ICONS[category];
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-3">
|
||||
{/* Category Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryIcon className={cn('w-4 h-4', color)} />
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{getCategoryName(category, formatMessage)}
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{templates.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Template Cards */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{templates.map((template) => {
|
||||
const isInstalled = installedTemplates.includes(template.id);
|
||||
const isInstalling = isLoading && !isInstalled;
|
||||
|
||||
return (
|
||||
<Card key={template.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatMessage({ id: `cliHooks.trigger.${template.trigger}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
|
||||
</p>
|
||||
{template.matcher && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<span className="font-mono bg-muted px-1 rounded">
|
||||
{template.matcher}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isInstalled ? 'outline' : 'default'}
|
||||
disabled={isInstalled || isInstalling}
|
||||
onClick={() => handleInstall(template.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isInstalled ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'cliHooks.templates.actions.installed' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'cliHooks.templates.actions.install' })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HookQuickTemplates;
|
||||
842
ccw/frontend/src/components/hook/HookWizard.tsx
Normal file
842
ccw/frontend/src/components/hook/HookWizard.tsx
Normal file
@@ -0,0 +1,842 @@
|
||||
// ========================================
|
||||
// Hook Wizard Component
|
||||
// ========================================
|
||||
// Multi-step wizard for creating common hook patterns
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Brain,
|
||||
Shield,
|
||||
Sparkles,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { fetchSkills, type Skill, createHook } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
detect,
|
||||
getShell,
|
||||
getShellName,
|
||||
checkCompatibility,
|
||||
getPlatformName,
|
||||
adjustCommandForPlatform,
|
||||
DEFAULT_PLATFORM_REQUIREMENTS,
|
||||
type Platform,
|
||||
} from '@/utils/platformUtils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* Supported wizard types
|
||||
*/
|
||||
export type WizardType = 'memory-update' | 'danger-protection' | 'skill-context';
|
||||
|
||||
/**
|
||||
* Wizard step number
|
||||
*/
|
||||
type WizardStep = 1 | 2 | 3;
|
||||
|
||||
/**
|
||||
* Component props
|
||||
*/
|
||||
export interface HookWizardProps {
|
||||
/** Type of wizard to launch */
|
||||
wizardType: WizardType;
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Callback when dialog is closed */
|
||||
onClose: () => void;
|
||||
/** Callback when wizard completes with hook configuration */
|
||||
onComplete: (hookConfig: {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
matcher?: string;
|
||||
command: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory update wizard configuration
|
||||
*/
|
||||
interface MemoryUpdateConfig {
|
||||
claudePath: string;
|
||||
updateFrequency: 'session-end' | 'hourly' | 'daily';
|
||||
sections: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Danger protection wizard configuration
|
||||
*/
|
||||
interface DangerProtectionConfig {
|
||||
keywords: string;
|
||||
confirmationMessage: string;
|
||||
allowBypass: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill context wizard configuration
|
||||
*/
|
||||
interface SkillContextConfig {
|
||||
keywordSkillPairs: Array<{ keyword: string; skill: string }>;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard configuration union type
|
||||
*/
|
||||
type WizardConfig = MemoryUpdateConfig | DangerProtectionConfig | SkillContextConfig;
|
||||
|
||||
// ========== Wizard Definitions ==========
|
||||
|
||||
/**
|
||||
* Wizard metadata for each type
|
||||
*/
|
||||
const WIZARD_METADATA = {
|
||||
'memory-update': {
|
||||
title: 'cliHooks.wizards.memoryUpdate.title',
|
||||
description: 'cliHooks.wizards.memoryUpdate.description',
|
||||
icon: Brain,
|
||||
trigger: 'Stop' as const,
|
||||
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['memory-update'],
|
||||
},
|
||||
'danger-protection': {
|
||||
title: 'cliHooks.wizards.dangerProtection.title',
|
||||
description: 'cliHooks.wizards.dangerProtection.description',
|
||||
icon: Shield,
|
||||
trigger: 'UserPromptSubmit' as const,
|
||||
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['danger-protection'],
|
||||
},
|
||||
'skill-context': {
|
||||
title: 'cliHooks.wizards.skillContext.title',
|
||||
description: 'cliHooks.wizards.skillContext.description',
|
||||
icon: Sparkles,
|
||||
trigger: 'UserPromptSubmit' as const,
|
||||
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['skill-context'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Get wizard icon component
|
||||
*/
|
||||
function getWizardIcon(type: WizardType) {
|
||||
return WIZARD_METADATA[type].icon;
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function HookWizard({
|
||||
wizardType,
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: HookWizardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>(1);
|
||||
const [detectedPlatform, setDetectedPlatform] = useState<Platform>('linux');
|
||||
|
||||
// Fetch available skills for skill-context wizard
|
||||
const { data: skillsData, isLoading: skillsLoading } = useQuery({
|
||||
queryKey: ['skills'],
|
||||
queryFn: fetchSkills,
|
||||
enabled: open && wizardType === 'skill-context',
|
||||
});
|
||||
|
||||
// Mutation for creating hook
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createHook,
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
setCurrentStep(1);
|
||||
},
|
||||
});
|
||||
|
||||
// Detect platform on mount
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDetectedPlatform(detect());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Wizard configuration state
|
||||
const [memoryConfig, setMemoryConfig] = useState<MemoryUpdateConfig>({
|
||||
claudePath: '.claude/CLAUDE.md',
|
||||
updateFrequency: 'session-end',
|
||||
sections: ['all'],
|
||||
});
|
||||
|
||||
const [dangerConfig, setDangerConfig] = useState<DangerProtectionConfig>({
|
||||
keywords: 'delete\nrm\nformat\ndrop\ntruncate\nshutdown',
|
||||
confirmationMessage: 'Are you sure you want to perform this action: {action}?',
|
||||
allowBypass: true,
|
||||
});
|
||||
|
||||
const [skillConfig, setSkillConfig] = useState<SkillContextConfig>({
|
||||
keywordSkillPairs: [{ keyword: '', skill: '' }],
|
||||
priority: 'medium',
|
||||
});
|
||||
|
||||
// Check platform compatibility
|
||||
const wizardMetadata = WIZARD_METADATA[wizardType];
|
||||
const compatibilityCheck = checkCompatibility(
|
||||
wizardMetadata.platformRequirements,
|
||||
detectedPlatform
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep((prev) => (prev + 1) as WizardStep);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as WizardStep);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
let hookConfig: {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
matcher?: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
const wizardName = formatMessage({ id: WIZARD_METADATA[wizardType].title });
|
||||
|
||||
switch (wizardType) {
|
||||
case 'memory-update':
|
||||
hookConfig = {
|
||||
name: `memory-update-${Date.now()}`,
|
||||
description: `${wizardName}: Update ${memoryConfig.claudePath} on ${memoryConfig.updateFrequency}`,
|
||||
trigger: wizardMetadata.trigger,
|
||||
command: buildMemoryUpdateCommand(memoryConfig, detectedPlatform),
|
||||
};
|
||||
break;
|
||||
|
||||
case 'danger-protection':
|
||||
hookConfig = {
|
||||
name: `danger-protection-${Date.now()}`,
|
||||
description: `${wizardName}: Confirm dangerous operations`,
|
||||
trigger: wizardMetadata.trigger,
|
||||
matcher: buildDangerMatcher(dangerConfig),
|
||||
command: buildDangerProtectionCommand(dangerConfig, detectedPlatform),
|
||||
};
|
||||
break;
|
||||
|
||||
case 'skill-context':
|
||||
hookConfig = {
|
||||
name: `skill-context-${Date.now()}`,
|
||||
description: `${wizardName}: Load SKILL based on keywords`,
|
||||
trigger: wizardMetadata.trigger,
|
||||
matcher: buildSkillMatcher(skillConfig),
|
||||
command: buildSkillContextCommand(skillConfig, detectedPlatform),
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(hookConfig);
|
||||
};
|
||||
|
||||
// Step renderers
|
||||
const renderStep1 = () => {
|
||||
const WizardIcon = getWizardIcon(wizardType);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Introduction */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b">
|
||||
<div className="p-3 rounded-lg bg-primary/10">
|
||||
<WizardIcon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: WIZARD_METADATA[wizardType].title })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: WIZARD_METADATA[wizardType].description })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Detection */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className={cn(
|
||||
'w-5 h-5',
|
||||
compatibilityCheck.compatible ? 'text-green-500' : 'text-destructive'
|
||||
)} />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.platform.detected' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getPlatformName(detectedPlatform)} ({getShellName(getShell(detectedPlatform))})
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={compatibilityCheck.compatible ? 'default' : 'destructive'}>
|
||||
{compatibilityCheck.compatible
|
||||
? formatMessage({ id: 'cliHooks.wizards.platform.compatible' })
|
||||
: formatMessage({ id: 'cliHooks.wizards.platform.incompatible' })
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Compatibility Issues */}
|
||||
{!compatibilityCheck.compatible && compatibilityCheck.issues.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-destructive/10 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{formatMessage({ id: 'cliHooks.wizards.platform.compatibilityError' })}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{compatibilityCheck.issues.map((issue, i) => (
|
||||
<li key={i} className="text-xs text-destructive/80">
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compatibility Warnings */}
|
||||
{compatibilityCheck.warnings.length > 0 && (
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-yellow-500/10 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-yellow-600">
|
||||
{formatMessage({ id: 'cliHooks.wizards.platform.compatibilityWarning' })}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{compatibilityCheck.warnings.map((warning, i) => (
|
||||
<li key={i} className="text-xs text-yellow-600/80">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Trigger Event */}
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.triggerEvent' })}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{formatMessage({ id: `cliHooks.trigger.${wizardMetadata.trigger}` })}
|
||||
</Badge>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep2 = () => {
|
||||
switch (wizardType) {
|
||||
case 'memory-update':
|
||||
return renderMemoryUpdateConfig();
|
||||
case 'danger-protection':
|
||||
return renderDangerProtectionConfig();
|
||||
case 'skill-context':
|
||||
return renderSkillContextConfig();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep3 = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center pb-4 border-b">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.review.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.review.description' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<Card className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.review.hookType' })}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: WIZARD_METADATA[wizardType].title })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.review.trigger' })}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatMessage({ id: `cliHooks.trigger.${wizardMetadata.trigger}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.review.platform' })}
|
||||
</span>
|
||||
<span className="text-sm text-foreground">
|
||||
{getPlatformName(detectedPlatform)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Configuration Summary */}
|
||||
{renderConfigSummary()}
|
||||
</Card>
|
||||
|
||||
{/* Command Preview */}
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{formatMessage({ id: 'cliHooks.wizards.steps.review.commandPreview' })}
|
||||
</p>
|
||||
<pre className="text-xs font-mono bg-muted p-3 rounded-lg overflow-x-auto">
|
||||
{getPreviewCommand()}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Configuration renderers
|
||||
const renderMemoryUpdateConfig = () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.claudePath' })}
|
||||
</label>
|
||||
<Input
|
||||
value={memoryConfig.claudePath}
|
||||
onChange={(e) => setMemoryConfig({ ...memoryConfig, claudePath: e.target.value })}
|
||||
placeholder=".claude/CLAUDE.md"
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.updateFrequency' })}
|
||||
</label>
|
||||
<Select
|
||||
value={memoryConfig.updateFrequency}
|
||||
onValueChange={(value: any) => setMemoryConfig({ ...memoryConfig, updateFrequency: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="session-end">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.sessionEnd' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="hourly">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.hourly' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="daily">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.daily' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDangerProtectionConfig = () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywords' })}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywordsHelp' })}
|
||||
</p>
|
||||
<Textarea
|
||||
value={dangerConfig.keywords}
|
||||
onChange={(e) => setDangerConfig({ ...dangerConfig, keywords: e.target.value })}
|
||||
placeholder="delete\nrm\nformat"
|
||||
className="mt-1 font-mono text-sm"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.confirmationMessage' })}
|
||||
</label>
|
||||
<Input
|
||||
value={dangerConfig.confirmationMessage}
|
||||
onChange={(e) => setDangerConfig({ ...dangerConfig, confirmationMessage: e.target.value })}
|
||||
placeholder="Are you sure you want to {action}?"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow-bypass"
|
||||
checked={dangerConfig.allowBypass}
|
||||
onChange={(e) => setDangerConfig({ ...dangerConfig, allowBypass: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="allow-bypass" className="text-sm text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.allowBypass' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSkillContextConfig = () => {
|
||||
const skills = skillsData?.skills ?? [];
|
||||
|
||||
const addPair = () => {
|
||||
setSkillConfig({
|
||||
...skillConfig,
|
||||
keywordSkillPairs: [...skillConfig.keywordSkillPairs, { keyword: '', skill: '' }],
|
||||
});
|
||||
};
|
||||
|
||||
const removePair = (index: number) => {
|
||||
setSkillConfig({
|
||||
...skillConfig,
|
||||
keywordSkillPairs: skillConfig.keywordSkillPairs.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
const updatePair = (index: number, field: 'keyword' | 'skill', value: string) => {
|
||||
const newPairs = [...skillConfig.keywordSkillPairs];
|
||||
newPairs[index][field] = value;
|
||||
setSkillConfig({ ...skillConfig, keywordSkillPairs: newPairs });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{skillsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.loadingSkills' })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{skillConfig.keywordSkillPairs.map((pair, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={pair.keyword}
|
||||
onChange={(e) => updatePair(index, 'keyword', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'cliHooks.wizards.skillContext.keywordPlaceholder' })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={pair.skill} onValueChange={(value) => updatePair(index, 'skill', value)}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder={formatMessage({ id: 'cliHooks.wizards.skillContext.selectSkill' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{skills.map((skill) => (
|
||||
<SelectItem key={skill.name} value={skill.name}>
|
||||
{skill.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{skillConfig.keywordSkillPairs.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePair(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={addPair} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.addPair' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.priority' })}
|
||||
</label>
|
||||
<Select
|
||||
value={skillConfig.priority}
|
||||
onValueChange={(value: any) => setSkillConfig({ ...skillConfig, priority: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityHigh' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityMedium' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="low">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityLow' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Config summary for review step
|
||||
const renderConfigSummary = () => {
|
||||
switch (wizardType) {
|
||||
case 'memory-update':
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.claudePath' })}
|
||||
</span>
|
||||
<span className="font-mono">{memoryConfig.claudePath}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.updateFrequency' })}
|
||||
</span>
|
||||
<span>
|
||||
{formatMessage({ id: `cliHooks.wizards.memoryUpdate.frequency.${memoryConfig.updateFrequency}` })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'danger-protection':
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywords' })}:
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{dangerConfig.keywords.split('\n').filter(Boolean).map((kw, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">
|
||||
{kw.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.allowBypass' })}
|
||||
</span>
|
||||
<span>{dangerConfig.allowBypass ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'skill-context':
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.keywordMappings' })}:
|
||||
</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{skillConfig.keywordSkillPairs
|
||||
.filter((p) => p.keyword && p.skill)
|
||||
.map((pair, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline">{pair.keyword}</Badge>
|
||||
<span className="text-muted-foreground">{'->'}</span>
|
||||
<Badge variant="secondary">{pair.skill}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.skillContext.priority' })}
|
||||
</span>
|
||||
<span>
|
||||
{formatMessage({ id: `cliHooks.wizards.skillContext.priority${skillConfig.priority.charAt(0).toUpperCase()}${skillConfig.priority.slice(1)}` })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get command preview for review step
|
||||
const getPreviewCommand = (): string => {
|
||||
switch (wizardType) {
|
||||
case 'memory-update':
|
||||
return buildMemoryUpdateCommand(memoryConfig, detectedPlatform);
|
||||
case 'danger-protection':
|
||||
return buildDangerProtectionCommand(dangerConfig, detectedPlatform);
|
||||
case 'skill-context':
|
||||
return buildSkillContextCommand(skillConfig, detectedPlatform);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation buttons
|
||||
const renderNavigation = () => (
|
||||
<DialogFooter className="gap-2">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrevious} disabled={createMutation.isPending}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'cliHooks.wizards.navigation.previous' })}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < 3 ? (
|
||||
<Button onClick={handleNext} disabled={!compatibilityCheck.compatible}>
|
||||
{formatMessage({ id: 'cliHooks.wizards.navigation.next' })}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleComplete} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending
|
||||
? formatMessage({ id: 'cliHooks.wizards.navigation.creating' })
|
||||
: formatMessage({ id: 'cliHooks.wizards.navigation.create' })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={handleClose} disabled={createMutation.isPending}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
// Step indicator
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex items-center justify-center gap-2 pb-4">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
|
||||
currentStep === step
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: currentStep > step
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{currentStep > step ? <CheckCircle className="w-4 h-4" /> : step}
|
||||
</div>
|
||||
{step < 3 && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-0.5 mx-1',
|
||||
currentStep > step ? 'bg-green-500' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{formatMessage({ id: 'cliHooks.wizards.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{renderStepIndicator()}
|
||||
|
||||
<div className="min-h-[300px]">
|
||||
{currentStep === 1 && renderStep1()}
|
||||
{currentStep === 2 && renderStep2()}
|
||||
{currentStep === 3 && renderStep3()}
|
||||
</div>
|
||||
|
||||
{renderNavigation()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default HookWizard;
|
||||
|
||||
// ========== Command Builders ==========
|
||||
|
||||
function buildMemoryUpdateCommand(config: MemoryUpdateConfig, platform: Platform): string {
|
||||
const shellCmd = getShellCommand(getShell(platform));
|
||||
const command = `echo "Updating ${config.claudePath} at ${config.updateFrequency}"`;
|
||||
return JSON.stringify([...shellCmd, command]);
|
||||
}
|
||||
|
||||
function buildDangerMatcher(config: DangerProtectionConfig): string {
|
||||
const keywords = config.keywords.split('\n').filter(Boolean).join('|');
|
||||
return `(${keywords})`;
|
||||
}
|
||||
|
||||
function buildDangerProtectionCommand(config: DangerProtectionConfig, platform: Platform): string {
|
||||
const shellCmd = getShellCommand(getShell(platform));
|
||||
const command = `echo "Checking for dangerous operations: ${config.keywords.split('\n').filter(Boolean).join(', ')}"`;
|
||||
return JSON.stringify([...shellCmd, command]);
|
||||
}
|
||||
|
||||
function buildSkillMatcher(config: SkillContextConfig): string {
|
||||
const keywords = config.keywordSkillPairs
|
||||
.filter((p) => p.keyword)
|
||||
.map((p) => p.keyword)
|
||||
.join('|');
|
||||
return `(${keywords})`;
|
||||
}
|
||||
|
||||
function buildSkillContextCommand(config: SkillContextConfig, platform: Platform): string {
|
||||
const pairs = config.keywordSkillPairs.filter((p) => p.keyword && p.skill);
|
||||
const command = `echo "Loading SKILL based on keywords: ${pairs.map((p) => p.keyword).join(', ')}"`;
|
||||
return JSON.stringify([...getShellCommand(getShell(platform)), command]);
|
||||
}
|
||||
19
ccw/frontend/src/components/hook/index.ts
Normal file
19
ccw/frontend/src/components/hook/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// ========================================
|
||||
// Hook Components Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { HookCard } from './HookCard';
|
||||
export type { HookCardProps, HookCardData, HookTriggerType } from './HookCard';
|
||||
|
||||
export { EventGroup } from './EventGroup';
|
||||
export type { EventGroupProps } from './EventGroup';
|
||||
|
||||
export { HookFormDialog } from './HookFormDialog';
|
||||
export type { HookFormDialogProps, HookFormMode, HookFormData } from './HookFormDialog';
|
||||
|
||||
export { HookQuickTemplates } from './HookQuickTemplates';
|
||||
export type { HookQuickTemplatesProps, HookTemplate, TemplateCategory } from './HookQuickTemplates';
|
||||
export { HOOK_TEMPLATES } from './HookQuickTemplates';
|
||||
|
||||
export { HookWizard } from './HookWizard';
|
||||
export type { HookWizardProps, WizardType } from './HookWizard';
|
||||
@@ -8,6 +8,10 @@ import { cn } from '@/lib/utils';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { MainContent } from './MainContent';
|
||||
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
|
||||
import { NotificationPanel } from '@/components/notification';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useWebSocketNotifications } from '@/hooks';
|
||||
|
||||
export interface AppShellProps {
|
||||
/** Initial sidebar collapsed state */
|
||||
@@ -44,6 +48,23 @@ export function AppShell({
|
||||
// Mobile sidebar open state
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// CLI Monitor open state
|
||||
const [isCliMonitorOpen, setIsCliMonitorOpen] = useState(false);
|
||||
|
||||
// Notification panel store integration
|
||||
const isNotificationPanelVisible = useNotificationStore((state) => state.isPanelVisible);
|
||||
const loadPersistentNotifications = useNotificationStore(
|
||||
(state) => state.loadPersistentNotifications
|
||||
);
|
||||
|
||||
// Initialize WebSocket notifications handler
|
||||
useWebSocketNotifications();
|
||||
|
||||
// Load persistent notifications from localStorage on mount
|
||||
useEffect(() => {
|
||||
loadPersistentNotifications();
|
||||
}, [loadPersistentNotifications]);
|
||||
|
||||
// Persist sidebar state
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(sidebarCollapsed));
|
||||
@@ -73,6 +94,18 @@ export function AppShell({
|
||||
setSidebarCollapsed(collapsed);
|
||||
}, []);
|
||||
|
||||
const handleCliMonitorClick = useCallback(() => {
|
||||
setIsCliMonitorOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCliMonitorClose = useCallback(() => {
|
||||
setIsCliMonitorOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleNotificationPanelClose = useCallback(() => {
|
||||
useNotificationStore.getState().setPanelVisible(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
{/* Header - fixed at top */}
|
||||
@@ -81,6 +114,7 @@ export function AppShell({
|
||||
projectPath={projectPath}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onCliMonitorClick={handleCliMonitorClick}
|
||||
/>
|
||||
|
||||
{/* Main layout - sidebar + content */}
|
||||
@@ -97,13 +131,25 @@ export function AppShell({
|
||||
<MainContent
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
// Adjust padding on mobile when sidebar is hidden
|
||||
'md:ml-0'
|
||||
// Add left margin on desktop to account for fixed sidebar
|
||||
sidebarCollapsed ? 'md:ml-16' : 'md:ml-64'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</MainContent>
|
||||
</div>
|
||||
|
||||
{/* CLI Stream Monitor - Global Drawer */}
|
||||
<CliStreamMonitor
|
||||
isOpen={isCliMonitorOpen}
|
||||
onClose={handleCliMonitorClose}
|
||||
/>
|
||||
|
||||
{/* Notification Panel - Global Drawer */}
|
||||
<NotificationPanel
|
||||
isOpen={isNotificationPanelVisible}
|
||||
onClose={handleNotificationPanelClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ import {
|
||||
Settings,
|
||||
User,
|
||||
LogOut,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
|
||||
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
|
||||
|
||||
export interface HeaderProps {
|
||||
/** Callback to toggle mobile sidebar */
|
||||
@@ -30,6 +34,8 @@ export interface HeaderProps {
|
||||
onRefresh?: () => void;
|
||||
/** Whether refresh is in progress */
|
||||
isRefreshing?: boolean;
|
||||
/** Callback to open CLI monitor */
|
||||
onCliMonitorClick?: () => void;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -37,9 +43,11 @@ export function Header({
|
||||
projectPath = '',
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
onCliMonitorClick,
|
||||
}: HeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const activeCliCount = useCliStreamStore(selectActiveExecutionCount);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (onRefresh && !isRefreshing) {
|
||||
@@ -47,11 +55,6 @@ export function Header({
|
||||
}
|
||||
}, [onRefresh, isRefreshing]);
|
||||
|
||||
// Get display path (truncate if too long)
|
||||
const displayPath = projectPath.length > 40
|
||||
? '...' + projectPath.slice(-37)
|
||||
: projectPath || formatMessage({ id: 'navigation.header.noProject' });
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
|
||||
@@ -83,14 +86,24 @@ export function Header({
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Project path indicator */}
|
||||
{projectPath && (
|
||||
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm text-muted-foreground max-w-[300px]">
|
||||
<span className="truncate" title={projectPath}>
|
||||
{displayPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* CLI Monitor button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCliMonitorClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">CLI Monitor</span>
|
||||
{activeCliCount > 0 && (
|
||||
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
||||
{activeCliCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Workspace selector */}
|
||||
{projectPath && <WorkspaceSelector />}
|
||||
|
||||
{/* Refresh button */}
|
||||
{onRefresh && (
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
LayoutDashboard,
|
||||
Clock,
|
||||
Zap,
|
||||
GitFork,
|
||||
Shield,
|
||||
History,
|
||||
Folder,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -58,7 +63,12 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
|
||||
{ path: '/skills', icon: Sparkles },
|
||||
{ path: '/commands', icon: Terminal },
|
||||
{ path: '/memory', icon: Brain },
|
||||
{ path: '/prompts', icon: History },
|
||||
{ path: '/hooks', icon: GitFork },
|
||||
{ path: '/explorer', icon: Folder },
|
||||
{ path: '/graph', icon: Network },
|
||||
{ path: '/settings', icon: Settings },
|
||||
{ path: '/settings/rules', icon: Shield },
|
||||
{ path: '/help', icon: HelpCircle },
|
||||
];
|
||||
|
||||
@@ -103,7 +113,12 @@ export function Sidebar({
|
||||
'/skills': 'main.skills',
|
||||
'/commands': 'main.commands',
|
||||
'/memory': 'main.memory',
|
||||
'/prompts': 'main.prompts',
|
||||
'/hooks': 'main.hooks',
|
||||
'/explorer': 'main.explorer',
|
||||
'/graph': 'main.graph',
|
||||
'/settings': 'main.settings',
|
||||
'/settings/rules': 'main.rules',
|
||||
'/help': 'main.help',
|
||||
};
|
||||
return navItemDefinitions.map((item) => ({
|
||||
@@ -127,12 +142,11 @@ export function Sidebar({
|
||||
<aside
|
||||
className={cn(
|
||||
'bg-sidebar-background border-r border-border flex flex-col transition-all duration-300',
|
||||
// Desktop styles
|
||||
'hidden md:flex sticky top-14 h-[calc(100vh-56px)]',
|
||||
// Desktop styles - fixed position for floating behavior
|
||||
'hidden md:flex fixed left-0 top-14 h-[calc(100vh-56px)] z-40',
|
||||
isCollapsed ? 'w-16' : 'w-64',
|
||||
// Mobile styles
|
||||
'md:translate-x-0',
|
||||
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
|
||||
mobileOpen && 'flex z-50 w-64 shadow-lg'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label={formatMessage({ id: 'navigation.header.brand' })}
|
||||
|
||||
433
ccw/frontend/src/components/mcp/CcwToolsMcpCard.tsx
Normal file
433
ccw/frontend/src/components/mcp/CcwToolsMcpCard.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
// ========================================
|
||||
// CCW Tools MCP Card Component
|
||||
// ========================================
|
||||
// Special card component for CCW Tools MCP server configuration
|
||||
// Displays tool checkboxes, path settings, and install/uninstall actions
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings,
|
||||
Check,
|
||||
FolderTree,
|
||||
Shield,
|
||||
Database,
|
||||
FileText,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
installCcwMcp,
|
||||
uninstallCcwMcp,
|
||||
updateCcwConfig,
|
||||
} from '@/lib/api';
|
||||
import { mcpServersKeys } from '@/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* CCW Tool definition with name, description, and core flag
|
||||
*/
|
||||
export interface CcwTool {
|
||||
name: string;
|
||||
desc: string;
|
||||
core: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CCW MCP configuration interface
|
||||
*/
|
||||
export interface CcwConfig {
|
||||
enabledTools: string[];
|
||||
projectRoot?: string;
|
||||
allowedDirs?: string;
|
||||
disableSandbox?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CcwToolsMcpCard component
|
||||
*/
|
||||
export interface CcwToolsMcpCardProps {
|
||||
/** Whether CCW MCP is installed */
|
||||
isInstalled: boolean;
|
||||
/** List of enabled tool names */
|
||||
enabledTools: string[];
|
||||
/** Project root path */
|
||||
projectRoot?: string;
|
||||
/** Comma-separated list of allowed directories */
|
||||
allowedDirs?: string;
|
||||
/** Whether sandbox is disabled */
|
||||
disableSandbox?: boolean;
|
||||
/** Callback when a tool is toggled */
|
||||
onToggleTool: (tool: string, enabled: boolean) => void;
|
||||
/** Callback when configuration is updated */
|
||||
onUpdateConfig: (config: Partial<CcwConfig>) => void;
|
||||
/** Callback when install/uninstall is triggered */
|
||||
onInstall: () => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/**
|
||||
* CCW MCP Tools definition
|
||||
* Available tools that can be enabled/disabled in CCW MCP server
|
||||
*/
|
||||
export const CCW_MCP_TOOLS: CcwTool[] = [
|
||||
{ name: 'write_file', desc: 'Write/create files', core: true },
|
||||
{ name: 'edit_file', desc: 'Edit/replace content', core: true },
|
||||
{ name: 'read_file', desc: 'Read file contents', core: true },
|
||||
{ name: 'core_memory', desc: 'Core memory management', core: true },
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CcwToolsMcpCard({
|
||||
isInstalled,
|
||||
enabledTools,
|
||||
projectRoot,
|
||||
allowedDirs,
|
||||
disableSandbox,
|
||||
onToggleTool,
|
||||
onUpdateConfig,
|
||||
onInstall,
|
||||
}: CcwToolsMcpCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local state for config inputs
|
||||
const [projectRootInput, setProjectRootInput] = useState(projectRoot || '');
|
||||
const [allowedDirsInput, setAllowedDirsInput] = useState(allowedDirs || '');
|
||||
const [disableSandboxInput, setDisableSandboxInput] = useState(disableSandbox || false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Mutations for install/uninstall
|
||||
const installMutation = useMutation({
|
||||
mutationFn: installCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
onInstall();
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: uninstallCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
onInstall();
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: updateCcwConfig,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleToggleTool = (toolName: string, enabled: boolean) => {
|
||||
onToggleTool(toolName, enabled);
|
||||
};
|
||||
|
||||
const handleEnableAll = () => {
|
||||
CCW_MCP_TOOLS.forEach((tool) => {
|
||||
if (!enabledTools.includes(tool.name)) {
|
||||
onToggleTool(tool.name, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisableAll = () => {
|
||||
enabledTools.forEach((toolName) => {
|
||||
onToggleTool(toolName, false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfigSave = () => {
|
||||
updateConfigMutation.mutate({
|
||||
projectRoot: projectRootInput || undefined,
|
||||
allowedDirs: allowedDirsInput || undefined,
|
||||
disableSandbox: disableSandboxInput,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInstallClick = () => {
|
||||
installMutation.mutate();
|
||||
};
|
||||
|
||||
const handleUninstallClick = () => {
|
||||
if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallConfirm' }))) {
|
||||
uninstallMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = installMutation.isPending || uninstallMutation.isPending || updateConfigMutation.isPending;
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
'overflow-hidden border-2',
|
||||
isInstalled ? 'border-primary/50 bg-primary/5' : 'border-dashed'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
isInstalled ? 'bg-primary/20' : 'bg-muted'
|
||||
)}>
|
||||
<Settings className={cn(
|
||||
'w-5 h-5',
|
||||
isInstalled ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.ccw.title' })}
|
||||
</span>
|
||||
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
|
||||
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
|
||||
</Badge>
|
||||
{isInstalled && (
|
||||
<Badge variant="outline" className="text-xs text-info">
|
||||
{formatMessage({ id: 'mcp.ccw.status.special' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'mcp.ccw.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
▼
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
▶
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||
{/* Quick Select Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEnableAll}
|
||||
disabled={!isInstalled}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.ccw.actions.enableAll' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisableAll}
|
||||
disabled={!isInstalled}
|
||||
>
|
||||
{formatMessage({ id: 'mcp.ccw.actions.disableAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tool Checkboxes */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'mcp.ccw.tools.label' })}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{CCW_MCP_TOOLS.map((tool) => {
|
||||
const isEnabled = enabledTools.includes(tool.name);
|
||||
const icon = getToolIcon(tool.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-2 rounded-lg transition-colors',
|
||||
isEnabled ? 'bg-background' : 'bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`ccw-tool-${tool.name}`}
|
||||
checked={isEnabled}
|
||||
onChange={(e) => handleToggleTool(tool.name, e.target.checked)}
|
||||
disabled={!isInstalled}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`ccw-tool-${tool.name}`}
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
>
|
||||
{icon}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: `mcp.ccw.tools.${tool.name}.name` })}
|
||||
</span>
|
||||
{tool.core && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.ccw.tools.core' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: `mcp.ccw.tools.${tool.name}.desc` })}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path Configuration */}
|
||||
<div className="space-y-3 pt-3 border-t border-border">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'mcp.ccw.paths.label' })}
|
||||
</p>
|
||||
|
||||
{/* Project Root */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-foreground flex items-center gap-1">
|
||||
<FolderTree className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.ccw.paths.projectRoot' })}
|
||||
</label>
|
||||
<Input
|
||||
value={projectRootInput}
|
||||
onChange={(e) => setProjectRootInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.ccw.paths.projectRootPlaceholder' })}
|
||||
disabled={!isInstalled}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Allowed Dirs */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-foreground flex items-center gap-1">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.ccw.paths.allowedDirs' })}
|
||||
</label>
|
||||
<Input
|
||||
value={allowedDirsInput}
|
||||
onChange={(e) => setAllowedDirsInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.ccw.paths.allowedDirsPlaceholder' })}
|
||||
disabled={!isInstalled}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.ccw.paths.allowedDirsHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Disable Sandbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ccw-disable-sandbox"
|
||||
checked={disableSandboxInput}
|
||||
onChange={(e) => setDisableSandboxInput(e.target.checked)}
|
||||
disabled={!isInstalled}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ccw-disable-sandbox"
|
||||
className="text-sm text-foreground flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.ccw.paths.disableSandbox' })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Save Config Button */}
|
||||
{isInstalled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConfigSave}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.saving' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.saveConfig' })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install/Uninstall Button */}
|
||||
<div className="pt-3 border-t border-border">
|
||||
{!isInstalled ? (
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.installing' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.install' })
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUninstallClick}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Get icon component for a tool name
|
||||
*/
|
||||
function getToolIcon(toolName: string): React.ReactElement {
|
||||
const iconProps = { className: 'w-4 h-4 text-muted-foreground' };
|
||||
|
||||
switch (toolName) {
|
||||
case 'write_file':
|
||||
return <FileText {...iconProps} />;
|
||||
case 'edit_file':
|
||||
return <Check {...iconProps} />;
|
||||
case 'read_file':
|
||||
return <Database {...iconProps} />;
|
||||
case 'core_memory':
|
||||
return <Settings {...iconProps} />;
|
||||
default:
|
||||
return <Settings {...iconProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default CcwToolsMcpCard;
|
||||
76
ccw/frontend/src/components/mcp/CliModeToggle.tsx
Normal file
76
ccw/frontend/src/components/mcp/CliModeToggle.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// ========================================
|
||||
// CLI Mode Toggle Component
|
||||
// ========================================
|
||||
// Toggle between Claude and Codex CLI modes with config path display
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Terminal, Cpu } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type CliMode = 'claude' | 'codex';
|
||||
|
||||
export interface CliModeToggleProps {
|
||||
currentMode: CliMode;
|
||||
onModeChange: (mode: CliMode) => void;
|
||||
codexConfigPath?: string;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CliModeToggle({
|
||||
currentMode,
|
||||
onModeChange,
|
||||
codexConfigPath,
|
||||
}: CliModeToggleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Mode Toggle Buttons */}
|
||||
<div className="flex gap-2 p-1 bg-muted rounded-lg">
|
||||
<Button
|
||||
variant={currentMode === 'claude' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onModeChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 gap-2',
|
||||
currentMode === 'claude' && 'shadow-sm'
|
||||
)}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'mcp.mode.claude' })}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentMode === 'codex' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onModeChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 gap-2',
|
||||
currentMode === 'codex' && 'shadow-sm'
|
||||
)}
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'mcp.mode.codex' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Codex Config Path Display */}
|
||||
{currentMode === 'codex' && codexConfigPath && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.codex.configPath' })}
|
||||
</Badge>
|
||||
<code className="text-xs text-muted-foreground font-mono truncate flex-1">
|
||||
{codexConfigPath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliModeToggle;
|
||||
152
ccw/frontend/src/components/mcp/CodexMcpCard.tsx
Normal file
152
ccw/frontend/src/components/mcp/CodexMcpCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
// ========================================
|
||||
// Codex MCP Card Component
|
||||
// ========================================
|
||||
// Read-only display card for Codex MCP servers (no edit/delete)
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Server,
|
||||
Power,
|
||||
PowerOff,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { McpServer } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CodexMcpCardProps {
|
||||
server: McpServer;
|
||||
enabled: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CodexMcpCard({
|
||||
server,
|
||||
enabled,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}: CodexMcpCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !enabled && 'opacity-60')}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
enabled ? 'bg-primary/10' : 'bg-muted'
|
||||
)}>
|
||||
<Server className={cn(
|
||||
'w-5 h-5',
|
||||
enabled ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{server.name}
|
||||
</span>
|
||||
{/* Read-only badge */}
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{formatMessage({ id: 'mcp.codex.readOnly' })}
|
||||
</Badge>
|
||||
{enabled && (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
<Power className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 font-mono">
|
||||
{server.command} {server.args?.join(' ') || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Disabled toggle button (visual only, no edit capability) */}
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-md flex items-center justify-center',
|
||||
enabled ? 'bg-green-100 text-green-600' : 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{enabled ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
|
||||
{/* Command details */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.command' })}</p>
|
||||
<code className="text-sm bg-background px-2 py-1 rounded block overflow-x-auto">
|
||||
{server.command}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.args' })}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{server.args.map((arg, idx) => (
|
||||
<Badge key={idx} variant="outline" className="font-mono text-xs">
|
||||
{arg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment variables */}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.env' })}</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(server.env).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" className="font-mono">{key}</Badge>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<code className="text-xs bg-background px-2 py-1 rounded flex-1 overflow-x-auto">
|
||||
{value as string}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only notice */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
|
||||
<Lock className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.codex.readOnlyNotice' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodexMcpCard;
|
||||
520
ccw/frontend/src/components/mcp/McpServerDialog.tsx
Normal file
520
ccw/frontend/src/components/mcp/McpServerDialog.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
// ========================================
|
||||
// MCP Server Dialog Component
|
||||
// ========================================
|
||||
// Add/Edit dialog for MCP server configuration with template presets
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
createMcpServer,
|
||||
updateMcpServer,
|
||||
fetchMcpServers,
|
||||
type McpServer,
|
||||
} from '@/lib/api';
|
||||
import { mcpServersKeys } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface McpServerDialogProps {
|
||||
mode: 'add' | 'edit';
|
||||
server?: McpServer;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
export interface McpTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface McpServerFormData {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
scope: 'project' | 'global';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
command?: string;
|
||||
args?: string;
|
||||
env?: string;
|
||||
}
|
||||
|
||||
// ========== Template Presets ==========
|
||||
|
||||
const TEMPLATE_PRESETS: McpTemplate[] = [
|
||||
{
|
||||
id: 'npx-stdio',
|
||||
name: 'NPX STDIO',
|
||||
description: 'Node.js package using stdio transport',
|
||||
command: 'npx',
|
||||
args: ['{package}'],
|
||||
},
|
||||
{
|
||||
id: 'python-stdio',
|
||||
name: 'Python STDIO',
|
||||
description: 'Python script using stdio transport',
|
||||
command: 'python',
|
||||
args: ['{script}.py'],
|
||||
},
|
||||
{
|
||||
id: 'sse-server',
|
||||
name: 'SSE Server',
|
||||
description: 'HTTP server with Server-Sent Events transport',
|
||||
command: 'node',
|
||||
args: ['{server}.js'],
|
||||
},
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function McpServerDialog({
|
||||
mode,
|
||||
server,
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
}: McpServerDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<McpServerFormData>({
|
||||
name: '',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
scope: 'project',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [argsInput, setArgsInput] = useState('');
|
||||
const [envInput, setEnvInput] = useState('');
|
||||
|
||||
// Initialize form from server prop (edit mode)
|
||||
useEffect(() => {
|
||||
if (server && mode === 'edit') {
|
||||
setFormData({
|
||||
name: server.name,
|
||||
command: server.command,
|
||||
args: server.args || [],
|
||||
env: server.env || {},
|
||||
scope: server.scope,
|
||||
enabled: server.enabled,
|
||||
});
|
||||
setArgsInput((server.args || []).join(', '));
|
||||
setEnvInput(
|
||||
Object.entries(server.env || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
);
|
||||
} else {
|
||||
// Reset form for add mode
|
||||
setFormData({
|
||||
name: '',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
scope: 'project',
|
||||
enabled: true,
|
||||
});
|
||||
setArgsInput('');
|
||||
setEnvInput('');
|
||||
}
|
||||
setSelectedTemplate('');
|
||||
setErrors({});
|
||||
}, [server, mode, open]);
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Omit<McpServer, 'name'>) => createMcpServer(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
handleClose();
|
||||
onSave?.();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ serverName, config }: { serverName: string; config: Partial<McpServer> }) =>
|
||||
updateMcpServer(serverName, config),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
handleClose();
|
||||
onSave?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleClose = () => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
const template = TEMPLATE_PRESETS.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
command: template.command,
|
||||
args: template.args,
|
||||
env: template.env || {},
|
||||
}));
|
||||
setArgsInput(template.args.join(', '));
|
||||
setEnvInput(
|
||||
Object.entries(template.env || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
);
|
||||
setSelectedTemplate(templateId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (
|
||||
field: keyof McpServerFormData,
|
||||
value: string | boolean | string[] | Record<string, string>
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear error for this field
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleArgsChange = (value: string) => {
|
||||
setArgsInput(value);
|
||||
const argsArray = value
|
||||
.split(',')
|
||||
.map((a) => a.trim())
|
||||
.filter((a) => a.length > 0);
|
||||
setFormData((prev) => ({ ...prev, args: argsArray }));
|
||||
if (errors.args) {
|
||||
setErrors((prev) => ({ ...prev, args: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvChange = (value: string) => {
|
||||
setEnvInput(value);
|
||||
const envObj: Record<string, string> = {};
|
||||
const lines = value.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && trimmed.includes('=')) {
|
||||
const [key, ...valParts] = trimmed.split('=');
|
||||
const val = valParts.join('=');
|
||||
if (key) {
|
||||
envObj[key.trim()] = val.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, env: envObj }));
|
||||
if (errors.env) {
|
||||
setErrors((prev) => ({ ...prev, env: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
// Name required
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = formatMessage({ id: 'mcp.dialog.validation.nameRequired' });
|
||||
}
|
||||
|
||||
// Command required
|
||||
if (!formData.command.trim()) {
|
||||
newErrors.command = formatMessage({ id: 'mcp.dialog.validation.commandRequired' });
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const checkNameExists = async (name: string): Promise<boolean> => {
|
||||
try {
|
||||
const data = await fetchMcpServers();
|
||||
const allServers = [...data.project, ...data.global];
|
||||
// In edit mode, exclude current server
|
||||
return allServers.some(
|
||||
(s) => s.name === name && (mode === 'edit' ? s.name !== server?.name : true)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if (await checkNameExists(formData.name)) {
|
||||
setErrors({ name: formatMessage({ id: 'mcp.dialog.validation.nameExists' }) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
createMutation.mutate({
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: formData.env,
|
||||
scope: formData.scope,
|
||||
enabled: formData.enabled,
|
||||
});
|
||||
} else {
|
||||
updateMutation.mutate({
|
||||
serverName: server!.name,
|
||||
config: {
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: formData.env,
|
||||
scope: formData.scope,
|
||||
enabled: formData.enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'add'
|
||||
? formatMessage({ id: 'mcp.dialog.addTitle' })
|
||||
: formatMessage({ id: 'mcp.dialog.editTitle' }, { name: server?.name })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Template Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.template' })}
|
||||
</label>
|
||||
<Select value={selectedTemplate} onValueChange={handleTemplateSelect}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEMPLATE_PRESETS.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{template.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.name' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.namePlaceholder' })}
|
||||
error={!!errors.name}
|
||||
disabled={mode === 'edit'} // Name cannot be changed in edit mode
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.command' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.command}
|
||||
onChange={(e) => handleFieldChange('command', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.commandPlaceholder' })}
|
||||
error={!!errors.command}
|
||||
/>
|
||||
{errors.command && (
|
||||
<p className="text-sm text-destructive">{errors.command}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.args' })}
|
||||
</label>
|
||||
<Input
|
||||
value={argsInput}
|
||||
onChange={(e) => handleArgsChange(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.argsPlaceholder' })}
|
||||
error={!!errors.args}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.argsHint' })}
|
||||
</p>
|
||||
{formData.args.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{formData.args.map((arg, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="font-mono text-xs">
|
||||
{arg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.args && (
|
||||
<p className="text-sm text-destructive">{errors.args}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.env' })}
|
||||
</label>
|
||||
<textarea
|
||||
value={envInput}
|
||||
onChange={(e) => handleEnvChange(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.envPlaceholder' })}
|
||||
className={cn(
|
||||
'flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
errors.env && 'border-destructive focus-visible:ring-destructive'
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.envHint' })}
|
||||
</p>
|
||||
{Object.keys(formData.env).length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{Object.entries(formData.env).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded flex-1 overflow-x-auto">
|
||||
{value}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.env && (
|
||||
<p className="text-sm text-destructive">{errors.env}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.scope' })}
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value="project"
|
||||
checked={formData.scope === 'project'}
|
||||
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{formatMessage({ id: 'mcp.scope.project' })}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="scope"
|
||||
value="global"
|
||||
checked={formData.scope === 'global'}
|
||||
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{formatMessage({ id: 'mcp.scope.global' })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => handleFieldChange('enabled', e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-foreground cursor-pointer">
|
||||
{formatMessage({ id: 'mcp.dialog.form.enabled' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{formatMessage({ id: 'mcp.dialog.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.dialog.actions.saving' })
|
||||
: formatMessage({ id: 'mcp.dialog.actions.save' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default McpServerDialog;
|
||||
405
ccw/frontend/src/components/notification/NotificationPanel.tsx
Normal file
405
ccw/frontend/src/components/notification/NotificationPanel.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
// ========================================
|
||||
// NotificationPanel Component
|
||||
// ========================================
|
||||
// Slide-over drawer notification panel with persistent notifications
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Bell,
|
||||
X,
|
||||
Check,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useNotificationStore, selectPersistentNotifications } from '@/stores';
|
||||
import type { Toast } from '@/types/store';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function formatTimeAgo(timestamp: string, formatMessage: (message: { id: string; values?: Record<string, unknown> }) => string): string {
|
||||
const now = Date.now();
|
||||
const time = new Date(timestamp).getTime();
|
||||
const diffMs = now - time;
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return formatMessage({ id: 'notifications.justNow' });
|
||||
if (minutes < 60) {
|
||||
return formatMessage({
|
||||
id: minutes === 1 ? 'notifications.oneMinuteAgo' : 'notifications.minutesAgo',
|
||||
values: { 0: String(minutes) }
|
||||
});
|
||||
}
|
||||
if (hours < 24) {
|
||||
return formatMessage({
|
||||
id: hours === 1 ? 'notifications.oneHourAgo' : 'notifications.hoursAgo',
|
||||
values: { 0: String(hours) }
|
||||
});
|
||||
}
|
||||
if (days < 7) {
|
||||
return formatMessage({
|
||||
id: days === 1 ? 'notifications.oneDayAgo' : 'notifications.daysAgo',
|
||||
values: { 0: String(days) }
|
||||
});
|
||||
}
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDetails(details: unknown): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
if (typeof details === 'string') return details;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
if (typeof details === 'object' && details !== null) {
|
||||
return JSON.stringify(details, null, 2);
|
||||
}
|
||||
return String(details);
|
||||
}
|
||||
|
||||
function getNotificationIcon(type: Toast['type']) {
|
||||
const iconClassName = 'h-4 w-4 shrink-0';
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className={cn(iconClassName, 'text-green-500')} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className={cn(iconClassName, 'text-yellow-500')} />;
|
||||
case 'error':
|
||||
return <XCircle className={cn(iconClassName, 'text-red-500')} />;
|
||||
case 'info':
|
||||
default:
|
||||
return <Info className={cn(iconClassName, 'text-blue-500')} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
interface PanelHeaderProps {
|
||||
notificationCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PanelHeader({ notificationCount, onClose }: PanelHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'notificationPanel.title' }) || 'Notifications'}
|
||||
</h2>
|
||||
{notificationCount > 0 && (
|
||||
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
||||
{notificationCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelActionsProps {
|
||||
hasNotifications: boolean;
|
||||
hasUnread: boolean;
|
||||
onMarkAllRead: () => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }: PanelActionsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!hasNotifications) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/30 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={!hasUnread}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'notificationPanel.markAllRead' }) || 'Mark Read'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'notificationPanel.clearAll' }) || 'Clear All'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Toast;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function NotificationItem({ notification, onDelete }: NotificationItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasDetails = notification.message && notification.message.length > 100;
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 border-b border-border hover:bg-muted/50 transition-colors',
|
||||
// Read opacity will be handled in T5 when read field is added
|
||||
'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">
|
||||
{notification.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatTimeAgo(notification.timestamp, formatMessage)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => onDelete(notification.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notification.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{isExpanded || !hasDetails
|
||||
? notification.message
|
||||
: notification.message.slice(0, 100) + '...'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Expand toggle */}
|
||||
{hasDetails && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-1 mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
{formatMessage({ id: 'notificationPanel.showLess' }) || 'Show less'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
{formatMessage({ id: 'notificationPanel.showMore' }) || 'Show more'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{notification.action && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={notification.action.onClick}
|
||||
className="mt-2 h-7 text-xs"
|
||||
>
|
||||
{notification.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NotificationListProps {
|
||||
notifications: Toast[];
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function NotificationList({ notifications, onDelete }: NotificationListProps) {
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function EmptyState({ message }: EmptyStateProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Bell className="h-16 w-16 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{message ||
|
||||
formatMessage({ id: 'notificationPanel.empty' }) ||
|
||||
'No notifications'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'notificationPanel.emptyHint' }) ||
|
||||
'Notifications will appear here'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export interface NotificationPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Store state
|
||||
const persistentNotifications = useNotificationStore(selectPersistentNotifications);
|
||||
const removePersistentNotification = useNotificationStore(
|
||||
(state) => state.removePersistentNotification
|
||||
);
|
||||
const clearPersistentNotifications = useNotificationStore(
|
||||
(state) => state.clearPersistentNotifications
|
||||
);
|
||||
|
||||
// Check if markAllAsRead exists (will be added in T5)
|
||||
const store = useNotificationStore.getState();
|
||||
const markAllAsRead = 'markAllAsRead' in store ? (store.markAllAsRead as () => void) : undefined;
|
||||
|
||||
// Reverse chronological order (newest first)
|
||||
const sortedNotifications = [...persistentNotifications].sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Delete handler
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
removePersistentNotification(id);
|
||||
},
|
||||
[removePersistentNotification]
|
||||
);
|
||||
|
||||
// Mark all read handler
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
if (markAllAsRead) {
|
||||
markAllAsRead();
|
||||
} else {
|
||||
// Placeholder for T5
|
||||
console.log('[NotificationPanel] markAllAsRead will be implemented in T5');
|
||||
}
|
||||
}, [markAllAsRead]);
|
||||
|
||||
// Clear all handler
|
||||
const handleClearAll = useCallback(() => {
|
||||
clearPersistentNotifications();
|
||||
}, [clearPersistentNotifications]);
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Check for unread notifications (will be enhanced in T5 with read field)
|
||||
// For now, all notifications are considered "unread" for UI purposes
|
||||
const hasUnread = sortedNotifications.length > 0;
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-full md:w-[480px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="notification-panel-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<PanelHeader notificationCount={sortedNotifications.length} onClose={onClose} />
|
||||
|
||||
{/* Action Bar */}
|
||||
<PanelActions
|
||||
hasNotifications={sortedNotifications.length > 0}
|
||||
hasUnread={hasUnread}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{sortedNotifications.length > 0 ? (
|
||||
<NotificationList
|
||||
notifications={sortedNotifications}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationPanel;
|
||||
7
ccw/frontend/src/components/notification/index.ts
Normal file
7
ccw/frontend/src/components/notification/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ========================================
|
||||
// Notification Components Index
|
||||
// ========================================
|
||||
// Centralized exports for notification components
|
||||
|
||||
export { NotificationPanel } from './NotificationPanel';
|
||||
export type { NotificationPanelProps } from './NotificationPanel';
|
||||
519
ccw/frontend/src/components/shared/CliStreamMonitor.tsx
Normal file
519
ccw/frontend/src/components/shared/CliStreamMonitor.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
// ========================================
|
||||
// CliStreamMonitor Component
|
||||
// ========================================
|
||||
// Global CLI streaming monitor with multi-execution support
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
X,
|
||||
Terminal,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Search,
|
||||
XCircle,
|
||||
ArrowDownToLine,
|
||||
Brain,
|
||||
Settings,
|
||||
Info,
|
||||
MessageCircle,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore, selectWsLastMessage } from '@/stores';
|
||||
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
|
||||
// ========== Types for CLI WebSocket Messages ==========
|
||||
|
||||
interface CliStreamStartedPayload {
|
||||
executionId: string;
|
||||
tool: string;
|
||||
mode: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CliStreamOutputPayload {
|
||||
executionId: string;
|
||||
chunkType: string;
|
||||
data: unknown;
|
||||
unit?: {
|
||||
content: unknown;
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CliStreamCompletedPayload {
|
||||
executionId: string;
|
||||
success: boolean;
|
||||
duration?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CliStreamErrorPayload {
|
||||
executionId: string;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
function getOutputLineIcon(type: CliOutputLine['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return <Brain className="h-3 w-3" />;
|
||||
case 'system':
|
||||
return <Settings className="h-3 w-3" />;
|
||||
case 'stderr':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
case 'metadata':
|
||||
return <Info className="h-3 w-3" />;
|
||||
case 'tool_call':
|
||||
return <Wrench className="h-3 w-3" />;
|
||||
case 'stdout':
|
||||
default:
|
||||
return <MessageCircle className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputLineClass(type: CliOutputLine['type']): string {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return 'text-purple-400';
|
||||
case 'system':
|
||||
return 'text-blue-400';
|
||||
case 'stderr':
|
||||
return 'text-red-400';
|
||||
case 'metadata':
|
||||
return 'text-yellow-400';
|
||||
case 'tool_call':
|
||||
return 'text-green-400';
|
||||
case 'stdout':
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export interface CliStreamMonitorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
|
||||
// Store state
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
|
||||
const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution);
|
||||
const removeExecution = useCliStreamStore((state) => state.removeExecution);
|
||||
|
||||
// Active execution sync
|
||||
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
|
||||
const invalidateActive = useInvalidateActiveCliExecutions();
|
||||
|
||||
// WebSocket last message from notification store
|
||||
const lastMessage = useNotificationStore(selectWsLastMessage);
|
||||
|
||||
// Handle WebSocket messages for CLI stream
|
||||
useEffect(() => {
|
||||
if (!lastMessage) return;
|
||||
|
||||
const { type, payload } = lastMessage;
|
||||
|
||||
if (type === 'CLI_STARTED') {
|
||||
const p = payload as CliStreamStartedPayload;
|
||||
const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
|
||||
useCliStreamStore.getState().upsertExecution(p.executionId, {
|
||||
tool: p.tool || 'cli',
|
||||
mode: p.mode || 'analysis',
|
||||
status: 'running',
|
||||
startTime,
|
||||
output: [
|
||||
{
|
||||
type: 'system',
|
||||
content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`,
|
||||
timestamp: startTime
|
||||
}
|
||||
]
|
||||
});
|
||||
// Set as current if none selected
|
||||
if (!currentExecutionId) {
|
||||
setCurrentExecution(p.executionId);
|
||||
}
|
||||
invalidateActive();
|
||||
} else if (type === 'CLI_OUTPUT') {
|
||||
const p = payload as CliStreamOutputPayload;
|
||||
const unitContent = p.unit?.content;
|
||||
const unitType = p.unit?.type || p.chunkType;
|
||||
|
||||
let content: string;
|
||||
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
|
||||
const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string };
|
||||
if (toolCall.action === 'invoke') {
|
||||
const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : '';
|
||||
content = `[Tool] ${toolCall.toolName}(${params})`;
|
||||
} else if (toolCall.action === 'result') {
|
||||
const status = toolCall.status || 'unknown';
|
||||
const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : '';
|
||||
content = `[Tool Result] ${status}${output}`;
|
||||
} else {
|
||||
content = JSON.stringify(unitContent);
|
||||
}
|
||||
} else {
|
||||
content = typeof p.data === 'string' ? p.data : JSON.stringify(p.data);
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const addOutput = useCliStreamStore.getState().addOutput;
|
||||
lines.forEach(line => {
|
||||
if (line.trim() || lines.length === 1) {
|
||||
addOutput(p.executionId, {
|
||||
type: (unitType as CliOutputLine['type']) || 'stdout',
|
||||
content: line,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (type === 'CLI_COMPLETED') {
|
||||
const p = payload as CliStreamCompletedPayload;
|
||||
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
|
||||
useCliStreamStore.getState().upsertExecution(p.executionId, {
|
||||
status: p.success ? 'completed' : 'error',
|
||||
endTime,
|
||||
output: [
|
||||
{
|
||||
type: 'system',
|
||||
content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`,
|
||||
timestamp: endTime
|
||||
}
|
||||
]
|
||||
});
|
||||
invalidateActive();
|
||||
} else if (type === 'CLI_ERROR') {
|
||||
const p = payload as CliStreamErrorPayload;
|
||||
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
|
||||
useCliStreamStore.getState().upsertExecution(p.executionId, {
|
||||
status: 'error',
|
||||
endTime,
|
||||
output: [
|
||||
{
|
||||
type: 'stderr',
|
||||
content: `[ERROR] ${p.error || 'Unknown error occurred'}`,
|
||||
timestamp: endTime
|
||||
}
|
||||
]
|
||||
});
|
||||
invalidateActive();
|
||||
}
|
||||
}, [lastMessage, currentExecutionId, setCurrentExecution, invalidateActive]);
|
||||
|
||||
// Auto-scroll to bottom when new output arrives
|
||||
useEffect(() => {
|
||||
if (autoScroll && !isUserScrolling && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [executions, autoScroll, isUserScrolling, currentExecutionId]);
|
||||
|
||||
// Handle scroll to detect user scrolling
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!logsContainerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setIsUserScrolling(false);
|
||||
}, []);
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose, searchQuery]);
|
||||
|
||||
// Get sorted execution IDs (running first, then by start time)
|
||||
const sortedExecutionIds = Object.keys(executions).sort((a, b) => {
|
||||
const execA = executions[a];
|
||||
const execB = executions[b];
|
||||
if (execA.status === 'running' && execB.status !== 'running') return -1;
|
||||
if (execA.status !== 'running' && execB.status === 'running') return 1;
|
||||
return execB.startTime - execA.startTime;
|
||||
});
|
||||
|
||||
// Active execution count for badge
|
||||
const activeCount = Object.values(executions).filter(e => e.status === 'running').length;
|
||||
|
||||
// Current execution
|
||||
const currentExecution = currentExecutionId ? executions[currentExecutionId] : null;
|
||||
|
||||
// Filter output lines based on search
|
||||
const filteredOutput = currentExecution && searchQuery
|
||||
? currentExecution.output.filter(line =>
|
||||
line.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: currentExecution?.output || [];
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-[600px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cli-monitor-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 id="cli-monitor-title" className="text-sm font-semibold text-foreground">
|
||||
CLI Stream Monitor
|
||||
</h2>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{activeCount} active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => refetch()}
|
||||
disabled={isSyncing}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isSyncing && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execution Tabs */}
|
||||
{sortedExecutionIds.length > 0 && (
|
||||
<div className="px-4 pt-3 bg-card border-b border-border">
|
||||
<Tabs
|
||||
value={currentExecutionId || ''}
|
||||
onValueChange={(v) => setCurrentExecution(v || null)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full h-auto flex-wrap gap-1 bg-secondary/50 p-1">
|
||||
{sortedExecutionIds.map((id) => {
|
||||
const exec = executions[id];
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
className={cn(
|
||||
'gap-1.5 text-xs px-2 py-1',
|
||||
exec.status === 'running' && 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full', {
|
||||
'bg-green-500 animate-pulse': exec.status === 'running',
|
||||
'bg-blue-500': exec.status === 'completed',
|
||||
'bg-red-500': exec.status === 'error'
|
||||
})} />
|
||||
<span className="font-medium">{exec.tool}</span>
|
||||
<span className="text-muted-foreground">{exec.mode}</span>
|
||||
{exec.recovered && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
|
||||
Recovered
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 ml-1 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeExecution(id);
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* Output Panel */}
|
||||
<div className="flex flex-col h-[calc(100vh-180px)]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-secondary/30 border-b border-border">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'cliMonitor.searchPlaceholder' }) || 'Search output...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSearchQuery('')} className="h-7 px-2">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentExecution && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(
|
||||
currentExecution.endTime
|
||||
? currentExecution.endTime - currentExecution.startTime
|
||||
: Date.now() - currentExecution.startTime
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filteredOutput.length} / {currentExecution.output.length} lines
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={autoScroll ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
className="h-7 px-2"
|
||||
title={autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
|
||||
>
|
||||
<ArrowDownToLine className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Content */}
|
||||
{currentExecution ? (
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-xs bg-background"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredOutput.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredOutput.map((line, index) => (
|
||||
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
<span className="break-all">{line.content}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
{isUserScrolling && filteredOutput.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-4 right-4"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{sortedExecutionIds.length === 0
|
||||
? (formatMessage({ id: 'cliMonitor.noExecutions' }) || 'No active CLI executions')
|
||||
: (formatMessage({ id: 'cliMonitor.selectExecution' }) || 'Select an execution to view output')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{sortedExecutionIds.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-16 w-16 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm mb-1">
|
||||
{formatMessage({ id: 'cliMonitor.noExecutions' }) || 'No active CLI executions'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'cliMonitor.noExecutionsHint' }) || 'Start a CLI command to see streaming output'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliStreamMonitor;
|
||||
@@ -1,53 +1,68 @@
|
||||
// ========================================
|
||||
// CliStreamPanel Component
|
||||
// ========================================
|
||||
// Floating panel for CLI execution details with streaming output
|
||||
// Turn-based CLI execution detail view
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Terminal, Clock, Calendar, Hash } from 'lucide-react';
|
||||
import { User, Bot, AlertTriangle, Info, Layers, Clock, Copy, Terminal, Hash, Calendar, CheckCircle2, XCircle, Timer } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { StreamingOutput } from './StreamingOutput';
|
||||
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import type { CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import type { ConversationRecord, ConversationTurn } from '@/lib/api';
|
||||
|
||||
// ========== Stable Selectors ==========
|
||||
// Create selector factory to avoid infinite re-renders
|
||||
// The selector function itself is stable, preventing unnecessary re-renders
|
||||
const createOutputsSelector = (executionId: string) => (state: ReturnType<typeof useCliStreamStore.getState>) =>
|
||||
state.outputs[executionId];
|
||||
type ViewMode = 'per-turn' | 'concatenated';
|
||||
type ConcatFormat = 'plain' | 'yaml' | 'json';
|
||||
|
||||
export interface CliStreamPanelProps {
|
||||
/** Execution ID to display */
|
||||
executionId: string;
|
||||
/** Source directory path */
|
||||
sourceDir?: string;
|
||||
/** Whether panel is open */
|
||||
open: boolean;
|
||||
/** Called when open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type TabValue = 'prompt' | 'output' | 'details';
|
||||
// ========== Types ==========
|
||||
|
||||
interface TurnSectionProps {
|
||||
turn: ConversationTurn;
|
||||
isLatest: boolean;
|
||||
}
|
||||
|
||||
interface ConcatenatedViewProps {
|
||||
prompt: string;
|
||||
format: ConcatFormat;
|
||||
onFormatChange: (fmt: ConcatFormat) => void;
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Format duration to human readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
const seconds = (ms / 1000).toFixed(1);
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon and color for a turn
|
||||
*/
|
||||
function getStatusInfo(status: string) {
|
||||
const statusMap = {
|
||||
success: { icon: CheckCircle2, color: 'text-green-600 dark:text-green-400' },
|
||||
error: { icon: XCircle, color: 'text-destructive' },
|
||||
timeout: { icon: Timer, color: 'text-warning' },
|
||||
};
|
||||
return statusMap[status as keyof typeof statusMap] || statusMap.error;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,28 +73,238 @@ function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'su
|
||||
gemini: 'info',
|
||||
codex: 'success',
|
||||
qwen: 'warning',
|
||||
opencode: 'secondary',
|
||||
};
|
||||
return variants[tool] || 'secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* CliStreamPanel component - Display CLI execution details in floating panel
|
||||
* Build concatenated prompt in specified format
|
||||
*/
|
||||
function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFormat, formatMessage: (message: { id: string }) => string): string {
|
||||
const turns = execution.turns;
|
||||
|
||||
if (format === 'plain') {
|
||||
const parts: string[] = [];
|
||||
parts.push(`=== ${formatMessage({ id: 'cli-manager.streamPanel.conversationHistory' })} ===`);
|
||||
parts.push('');
|
||||
|
||||
for (const turn of turns) {
|
||||
parts.push(`--- Turn ${turn.turn} ---`);
|
||||
parts.push('USER:');
|
||||
parts.push(turn.prompt);
|
||||
parts.push('');
|
||||
parts.push('ASSISTANT:');
|
||||
parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' }));
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
parts.push(`=== ${formatMessage({ id: 'cli-manager.streamPanel.newRequest' })} ===`);
|
||||
parts.push('');
|
||||
parts.push(formatMessage({ id: 'cli-manager.streamPanel.yourNextPrompt' }));
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
if (format === 'yaml') {
|
||||
const yaml: string[] = [];
|
||||
yaml.push('conversation:');
|
||||
yaml.push(' turns:');
|
||||
|
||||
for (const turn of turns) {
|
||||
yaml.push(` - turn: ${turn.turn}`);
|
||||
yaml.push(` timestamp: ${turn.timestamp}`);
|
||||
yaml.push(` prompt: |`);
|
||||
turn.prompt.split('\n').forEach(line => {
|
||||
yaml.push(` ${line}`);
|
||||
});
|
||||
yaml.push(` response: |`);
|
||||
const output = turn.output.stdout || '';
|
||||
if (output) {
|
||||
output.split('\n').forEach(line => {
|
||||
yaml.push(` ${line}`);
|
||||
});
|
||||
} else {
|
||||
yaml.push(` ${formatMessage({ id: 'cli-manager.streamPanel.noOutput' })}`);
|
||||
}
|
||||
}
|
||||
|
||||
return yaml.join('\n');
|
||||
}
|
||||
|
||||
// JSON format
|
||||
return JSON.stringify(
|
||||
turns.map((t) => ({
|
||||
turn: t.turn,
|
||||
timestamp: t.timestamp,
|
||||
prompt: t.prompt,
|
||||
response: t.output.stdout || '',
|
||||
})),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
/**
|
||||
* TurnSection - Single turn display with header and content
|
||||
*/
|
||||
function TurnSection({ turn, isLatest }: TurnSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const StatusIcon = getStatusInfo(turn.status as string).icon;
|
||||
const statusColor = getStatusInfo(turn.status as string).color;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'overflow-hidden transition-all',
|
||||
isLatest && 'ring-2 ring-primary/50 shadow-md'
|
||||
)}
|
||||
>
|
||||
{/* Turn Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-medium" aria-hidden="true">
|
||||
{turn.turn === 1 ? '\u25B6' : '\u21B3'} {/* ▶ or ↳ */}
|
||||
</span>
|
||||
<span className="font-semibold text-sm">{formatMessage({ id: 'cli.details.turn' })} {turn.turn}</span>
|
||||
{isLatest && (
|
||||
<Badge variant="default" className="text-xs h-5 px-1.5">
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.latest' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1" title={formatMessage({ id: 'cli.details.timestamp' })}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(turn.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={cn('flex items-center gap-1 font-medium', statusColor)} title={formatMessage({ id: 'cli.details.status' })}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
{turn.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs" title={formatMessage({ id: 'cli.details.duration' })}>
|
||||
{formatDuration(turn.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Turn Body */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* User Prompt */}
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold mb-2 text-foreground">
|
||||
<User className="h-4 w-4 text-primary" aria-hidden="true" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.userPrompt' })}
|
||||
</h4>
|
||||
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
|
||||
{turn.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Assistant Response */}
|
||||
{turn.output.stdout && (
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold mb-2 text-foreground">
|
||||
<Bot className="h-4 w-4 text-blue-500" aria-hidden="true" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.assistantResponse' })}
|
||||
</h4>
|
||||
<pre className="p-3 bg-blue-500/5 dark:bg-blue-500/10 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
|
||||
{turn.output.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{turn.output.stderr && (
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold mb-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.errors' })}
|
||||
</h4>
|
||||
<pre className="p-3 bg-destructive/10 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed text-destructive">
|
||||
{turn.output.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Truncated Notice */}
|
||||
{turn.output.truncated && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-3 bg-muted/50 rounded-lg border border-border/50">
|
||||
<Info className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span>{formatMessage({ id: 'cli-manager.streamPanel.truncatedNotice' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PerTurnView - Display all turns as separate sections with connectors
|
||||
*/
|
||||
function PerTurnView({ turns }: { turns: ConversationTurn[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{turns.map((turn, idx) => (
|
||||
<React.Fragment key={turn.turn}>
|
||||
<TurnSection turn={turn} isLatest={idx === turns.length - 1} />
|
||||
{/* Connector line between turns */}
|
||||
{idx < turns.length - 1 && (
|
||||
<div className="flex justify-center" aria-hidden="true">
|
||||
<div className="w-px h-6 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ConcatenatedView - Display all turns merged into a single prompt
|
||||
*/
|
||||
function ConcatenatedView({ prompt, format, onFormatChange }: ConcatenatedViewProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Layers className="h-4 w-4" aria-hidden="true" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.concatenatedPrompt' })}
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
{(['plain', 'yaml', 'json'] as const).map((fmt) => (
|
||||
<Button
|
||||
key={fmt}
|
||||
size="sm"
|
||||
variant={format === fmt ? 'default' : 'outline'}
|
||||
onClick={() => onFormatChange(fmt)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-[60vh] overflow-y-auto">
|
||||
{prompt}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* CliStreamPanel component - Elegant turn-based conversation view
|
||||
*
|
||||
* @remarks
|
||||
* Shows execution details with three tabs:
|
||||
* - Prompt: View the conversation prompts
|
||||
* - Output: Real-time streaming output
|
||||
* - Details: Execution metadata (tool, mode, duration, etc.)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CliStreamPanel
|
||||
* executionId="exec-123"
|
||||
* sourceDir="/path/to/project"
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* />
|
||||
* ```
|
||||
* Displays CLI execution details with:
|
||||
* - Per-turn view with timeline layout
|
||||
* - Concatenated view for resume context
|
||||
* - Format selection (Plain/YAML/JSON)
|
||||
*/
|
||||
export function CliStreamPanel({
|
||||
executionId,
|
||||
@@ -88,49 +313,30 @@ export function CliStreamPanel({
|
||||
onOpenChange,
|
||||
}: CliStreamPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = React.useState<TabValue>('output');
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('per-turn');
|
||||
const [concatFormat, setConcatFormat] = React.useState<ConcatFormat>('plain');
|
||||
|
||||
// Fetch execution details
|
||||
const { data: execution, isLoading, error } = useCliExecutionDetail(
|
||||
open ? executionId : null,
|
||||
{ enabled: open }
|
||||
);
|
||||
const { data: execution, isLoading } = useCliExecutionDetail(open ? executionId : null);
|
||||
|
||||
// Get streaming outputs from store using stable selector
|
||||
// Use selector factory to prevent infinite re-renders
|
||||
const selectOutputs = React.useMemo(
|
||||
() => createOutputsSelector(executionId),
|
||||
[executionId]
|
||||
);
|
||||
const outputs = useCliStreamStore(selectOutputs) || [];
|
||||
// Build concatenated prompt
|
||||
const concatenatedPrompt = React.useMemo(() => {
|
||||
if (!execution?.turns) return '';
|
||||
return buildConcatenatedPrompt(execution, concatFormat, formatMessage);
|
||||
}, [execution, concatFormat, formatMessage]);
|
||||
|
||||
// Build output lines from conversation (historical) + streaming (real-time)
|
||||
const allOutputs: CliOutputLine[] = React.useMemo(() => {
|
||||
const historical: CliOutputLine[] = [];
|
||||
|
||||
// Add historical output from conversation turns
|
||||
if (execution?.turns) {
|
||||
for (const turn of execution.turns) {
|
||||
if (turn.output?.stdout) {
|
||||
historical.push({
|
||||
type: 'stdout',
|
||||
content: turn.output.stdout,
|
||||
timestamp: new Date(turn.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
if (turn.output?.stderr) {
|
||||
historical.push({
|
||||
type: 'stderr',
|
||||
content: turn.output.stderr,
|
||||
timestamp: new Date(turn.timestamp).getTime(),
|
||||
});
|
||||
}
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = React.useCallback(
|
||||
async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// Optional: add toast notification here
|
||||
console.log(`Copied ${label} to clipboard`);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine historical + streaming
|
||||
return [...historical, ...outputs];
|
||||
}, [execution, outputs]);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate total duration
|
||||
const totalDuration = React.useMemo(() => {
|
||||
@@ -140,132 +346,112 @@ export function CliStreamPanel({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border">
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
{formatMessage({ id: 'cli.executionDetails' })}
|
||||
{formatMessage({ id: 'cli-manager.executionDetails' })}
|
||||
</DialogTitle>
|
||||
|
||||
{/* Execution info badges */}
|
||||
{execution && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getToolVariant(execution.tool)}>
|
||||
<Badge variant={getToolVariant(execution.tool)} title={formatMessage({ id: 'cli.details.tool' })}>
|
||||
{execution.tool.toUpperCase()}
|
||||
</Badge>
|
||||
{execution.mode && (
|
||||
<Badge variant="secondary">{execution.mode}</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{execution.mode && <Badge variant="secondary" title={formatMessage({ id: 'cli.details.mode' })}>{execution.mode}</Badge>}
|
||||
<span className="text-sm text-muted-foreground font-mono" title={formatMessage({ id: 'cli.details.duration' })}>
|
||||
{formatDuration(totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{execution && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-2">
|
||||
<span className="flex items-center gap-1" title={formatMessage({ id: 'cli.details.created' })}>
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(execution.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title={formatMessage({ id: 'cli.details.id' })}>
|
||||
<Hash className="h-3 w-3" />
|
||||
{execution.id.slice(0, 8)}
|
||||
</span>
|
||||
<span>{execution.turn_count} {formatMessage({ id: 'cli-manager.streamPanel.turns' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
<div className="text-muted-foreground">{formatMessage({ id: 'cli-manager.streamPanel.loading' })}</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center text-destructive">
|
||||
Failed to load execution details
|
||||
</div>
|
||||
) : execution ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="prompt">
|
||||
{formatMessage({ id: 'cli.tabs.prompt' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="output">
|
||||
{formatMessage({ id: 'cli.tabs.output' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="details">
|
||||
{formatMessage({ id: 'cli.tabs.details' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
) : execution?.turns && execution.turns.length > 0 ? (
|
||||
<>
|
||||
{/* View Toggle - Only show for multi-turn conversations */}
|
||||
{execution.turns.length > 1 && (
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === 'per-turn' ? 'default' : 'outline'}
|
||||
onClick={() => setViewMode('per-turn')}
|
||||
className="h-8"
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.perTurnView' })}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === 'concatenated' ? 'default' : 'outline'}
|
||||
onClick={() => setViewMode('concatenated')}
|
||||
className="h-8"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.concatenatedView' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{viewMode === 'per-turn' ? (
|
||||
<PerTurnView turns={execution.turns} />
|
||||
) : (
|
||||
<ConcatenatedView
|
||||
prompt={concatenatedPrompt}
|
||||
format={concatFormat}
|
||||
onFormatChange={setConcatFormat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6">
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="mt-4 h-full overflow-y-auto m-0"
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center gap-2 px-6 py-4 border-t bg-muted/30 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(execution.id, 'ID')}
|
||||
className="h-8"
|
||||
>
|
||||
<div className="p-4 bg-muted rounded-lg max-h-[50vh] overflow-y-auto">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{execution.turns.map((turn, i) => (
|
||||
<div key={i} className="mb-4">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Turn {turn.turn}
|
||||
</div>
|
||||
<div>{turn.prompt}</div>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="output"
|
||||
className="mt-4 h-full m-0"
|
||||
>
|
||||
<div className="h-[50vh] border border-border rounded-lg overflow-hidden">
|
||||
<StreamingOutput
|
||||
outputs={allOutputs}
|
||||
isStreaming={outputs.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="details"
|
||||
className="mt-4 h-full overflow-y-auto m-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Tool:</span>
|
||||
<Badge variant={getToolVariant(execution.tool)}>
|
||||
{execution.tool}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Mode:</span>
|
||||
<span>{execution.mode || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Duration:</span>
|
||||
<span>{formatDuration(totalDuration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Created:</span>
|
||||
<span>
|
||||
{new Date(execution.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
ID: {execution.id}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Turns: {execution.turn_count}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.copyId' })}
|
||||
</Button>
|
||||
{execution.turns.length > 1 && viewMode === 'concatenated' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(concatenatedPrompt, 'prompt')}
|
||||
className="h-8"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.copyPrompt' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.noDetails' })}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
338
ccw/frontend/src/components/shared/ExplorerToolbar.tsx
Normal file
338
ccw/frontend/src/components/shared/ExplorerToolbar.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
// ========================================
|
||||
// ExplorerToolbar Component
|
||||
// ========================================
|
||||
// Toolbar component for File Explorer with search and controls
|
||||
|
||||
import { Search, X, ChevronDown, RefreshCw, List, Grid, ChevronRight, ChevronDown as ChevronDownIcon } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import type { RootDirectory } from '@/lib/api';
|
||||
import type { ExplorerViewMode, ExplorerSortOrder } from '@/types/file-explorer';
|
||||
|
||||
export interface ExplorerToolbarProps {
|
||||
/** Current search query */
|
||||
searchQuery: string;
|
||||
/** Callback when search query changes */
|
||||
onSearchChange: (query: string) => void;
|
||||
/** Callback when search is cleared */
|
||||
onSearchClear: () => void;
|
||||
/** Callback when refresh is requested */
|
||||
onRefresh: () => void;
|
||||
/** Available root directories */
|
||||
rootDirectories: RootDirectory[];
|
||||
/** Currently selected root directory */
|
||||
selectedRoot: string;
|
||||
/** Callback when root directory changes */
|
||||
onRootChange: (path: string) => void;
|
||||
/** Loading state for root directories */
|
||||
isLoadingRoots?: boolean;
|
||||
/** Current view mode */
|
||||
viewMode: ExplorerViewMode;
|
||||
/** Callback when view mode changes */
|
||||
onViewModeChange: (mode: ExplorerViewMode) => void;
|
||||
/** Current sort order */
|
||||
sortOrder: ExplorerSortOrder;
|
||||
/** Callback when sort order changes */
|
||||
onSortOrderChange: (order: ExplorerSortOrder) => void;
|
||||
/** Whether to show hidden files */
|
||||
showHiddenFiles: boolean;
|
||||
/** Callback when show hidden files toggles */
|
||||
onToggleShowHidden: () => void;
|
||||
/** Callback to expand all directories */
|
||||
onExpandAll?: () => void;
|
||||
/** Callback to collapse all directories */
|
||||
onCollapseAll?: () => void;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get root directory display name
|
||||
*/
|
||||
function getRootDisplayName(root: RootDirectory): string {
|
||||
if (root.name) return root.name;
|
||||
const parts = root.path.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || root.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExplorerToolbar component
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ExplorerToolbar
|
||||
* searchQuery={filter}
|
||||
* onSearchChange={setFilter}
|
||||
* onSearchClear={() => setFilter('')}
|
||||
* onRefresh={refetch}
|
||||
* rootDirectories={rootDirectories}
|
||||
* selectedRoot={rootPath}
|
||||
* onRootChange={(path) => setRootPath(path)}
|
||||
* viewMode={viewMode}
|
||||
* onViewModeChange={setViewMode}
|
||||
* sortOrder={sortOrder}
|
||||
* onSortOrderChange={setSortOrder}
|
||||
* showHiddenFiles={showHiddenFiles}
|
||||
* onToggleShowHidden={toggleShowHidden}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ExplorerToolbar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSearchClear,
|
||||
onRefresh,
|
||||
rootDirectories,
|
||||
selectedRoot,
|
||||
onRootChange,
|
||||
isLoadingRoots = false,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
sortOrder,
|
||||
onSortOrderChange,
|
||||
showHiddenFiles,
|
||||
onToggleShowHidden,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
className,
|
||||
}: ExplorerToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const selectedRootDir = rootDirectories.find((r) => r.path === selectedRoot);
|
||||
|
||||
// Handle sort order change
|
||||
const handleSortOrderChange = (order: ExplorerSortOrder) => {
|
||||
onSortOrderChange(order);
|
||||
};
|
||||
|
||||
// Handle view mode change
|
||||
const handleViewModeChange = (mode: ExplorerViewMode) => {
|
||||
onViewModeChange(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/30', className)}>
|
||||
{/* Root directory selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 max-w-[200px]">
|
||||
<span className="truncate">
|
||||
{selectedRootDir
|
||||
? getRootDisplayName(selectedRootDir)
|
||||
: formatMessage({ id: 'explorer.toolbar.selectRoot' })
|
||||
}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'explorer.toolbar.rootDirectory' })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{rootDirectories.map((root) => (
|
||||
<DropdownMenuItem
|
||||
key={root.path}
|
||||
onClick={() => onRootChange(root.path)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 cursor-pointer',
|
||||
selectedRoot === root.path && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate">{getRootDisplayName(root)}</span>
|
||||
{root.isWorkspace && (
|
||||
<span className="text-xs text-primary">WS</span>
|
||||
)}
|
||||
{root.isGitRoot && (
|
||||
<span className="text-xs text-success">GIT</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{rootDirectories.length === 0 && !isLoadingRoots && (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'explorer.toolbar.noRoots' })}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex-1 max-w-sm relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'explorer.toolbar.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 pr-9 h-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={onSearchClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={formatMessage({ id: 'common.actions.clear' })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
title={formatMessage({ id: 'common.actions.refresh' })}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* View mode dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={formatMessage({ id: 'explorer.toolbar.viewMode' })}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{viewMode === 'tree' ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : viewMode === 'list' ? (
|
||||
<List className="h-4 w-4" />
|
||||
) : (
|
||||
<Grid className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'explorer.toolbar.viewMode' })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleViewModeChange('tree')}
|
||||
className={cn('cursor-pointer', viewMode === 'tree' && 'bg-accent')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'explorer.viewMode.tree' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleViewModeChange('list')}
|
||||
className={cn('cursor-pointer', viewMode === 'list' && 'bg-accent')}
|
||||
>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'explorer.viewMode.list' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleViewModeChange('compact')}
|
||||
className={cn('cursor-pointer', viewMode === 'compact' && 'bg-accent')}
|
||||
>
|
||||
<Grid className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'explorer.viewMode.compact' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Sort order dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={formatMessage({ id: 'explorer.toolbar.sortBy' })}
|
||||
className="h-8"
|
||||
>
|
||||
{formatMessage({ id: `explorer.sortOrder.${sortOrder}` })}
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'explorer.toolbar.sortBy' })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortOrderChange('name')}
|
||||
className={cn('cursor-pointer', sortOrder === 'name' && 'bg-accent')}
|
||||
>
|
||||
{formatMessage({ id: 'explorer.sortOrder.name' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortOrderChange('size')}
|
||||
className={cn('cursor-pointer', sortOrder === 'size' && 'bg-accent')}
|
||||
>
|
||||
{formatMessage({ id: 'explorer.sortOrder.size' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortOrderChange('modified')}
|
||||
className={cn('cursor-pointer', sortOrder === 'modified' && 'bg-accent')}
|
||||
>
|
||||
{formatMessage({ id: 'explorer.sortOrder.modified' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSortOrderChange('type')}
|
||||
className={cn('cursor-pointer', sortOrder === 'type' && 'bg-accent')}
|
||||
>
|
||||
{formatMessage({ id: 'explorer.sortOrder.type' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* More options dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={formatMessage({ id: 'explorer.toolbar.moreOptions' })}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'explorer.toolbar.options' })}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onToggleShowHidden}
|
||||
className={cn('cursor-pointer justify-between', showHiddenFiles && 'bg-accent')}
|
||||
>
|
||||
<span>{formatMessage({ id: 'explorer.toolbar.showHidden' })}</span>
|
||||
{showHiddenFiles && <span className="text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
{onExpandAll && (
|
||||
<DropdownMenuItem
|
||||
onClick={onExpandAll}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{formatMessage({ id: 'explorer.toolbar.expandAll' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCollapseAll && (
|
||||
<DropdownMenuItem
|
||||
onClick={onCollapseAll}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{formatMessage({ id: 'explorer.toolbar.collapseAll' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExplorerToolbar;
|
||||
325
ccw/frontend/src/components/shared/FilePreview.tsx
Normal file
325
ccw/frontend/src/components/shared/FilePreview.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
// ========================================
|
||||
// FilePreview Component
|
||||
// ========================================
|
||||
// File content preview with syntax highlighting
|
||||
|
||||
import * as React from 'react';
|
||||
import { File, Copy, Check, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { FileContent } from '@/types/file-explorer';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
/** File content to display */
|
||||
fileContent: FileContent | null | undefined;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Error message */
|
||||
error?: string | null;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
/** Maximum file size to preview in bytes */
|
||||
maxSize?: number;
|
||||
/** Whether to show line numbers */
|
||||
showLineNumbers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language display name
|
||||
*/
|
||||
function getLanguageDisplayName(language?: string): string {
|
||||
if (!language) return 'Plain Text';
|
||||
|
||||
const languageNames: Record<string, string> = {
|
||||
'typescript': 'TypeScript',
|
||||
'tsx': 'TypeScript JSX',
|
||||
'javascript': 'JavaScript',
|
||||
'jsx': 'React JSX',
|
||||
'python': 'Python',
|
||||
'ruby': 'Ruby',
|
||||
'go': 'Go',
|
||||
'rust': 'Rust',
|
||||
'java': 'Java',
|
||||
'csharp': 'C#',
|
||||
'php': 'PHP',
|
||||
'scala': 'Scala',
|
||||
'kotlin': 'Kotlin',
|
||||
'markdown': 'Markdown',
|
||||
'json': 'JSON',
|
||||
'yaml': 'YAML',
|
||||
'xml': 'XML',
|
||||
'html': 'HTML',
|
||||
'css': 'CSS',
|
||||
'scss': 'SCSS',
|
||||
'less': 'Less',
|
||||
'sql': 'SQL',
|
||||
'bash': 'Bash',
|
||||
'text': 'Plain Text',
|
||||
};
|
||||
|
||||
return languageNames[language] || language.charAt(0).toUpperCase() + language.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from path
|
||||
*/
|
||||
function getFileExtension(path: string): string {
|
||||
const parts = path.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is likely binary
|
||||
*/
|
||||
function isBinaryFile(path: string): boolean {
|
||||
const binaryExtensions = [
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg',
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'zip', 'tar', 'gz', 'rar', '7z',
|
||||
'mp3', 'mp4', 'avi', 'mov', 'wav',
|
||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||
'exe', 'dll', 'so', 'dylib',
|
||||
'class', 'jar', 'war',
|
||||
'pdb', 'obj', 'o',
|
||||
];
|
||||
|
||||
const ext = getFileExtension(path).toLowerCase();
|
||||
return binaryExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content if too large
|
||||
*/
|
||||
function truncateContent(content: string, maxLines: number = 1000): string {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length <= maxLines) return content;
|
||||
|
||||
return lines.slice(0, maxLines).join('\n') + `\n\n... (${lines.length - maxLines} more lines)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* FilePreview component
|
||||
*/
|
||||
export function FilePreview({
|
||||
fileContent,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
className,
|
||||
maxSize = 1024 * 1024, // 1MB default
|
||||
showLineNumbers = true,
|
||||
}: FilePreviewProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const contentRef = React.useRef<HTMLPreElement>(null);
|
||||
|
||||
// Copy content to clipboard
|
||||
const handleCopy = async () => {
|
||||
if (!fileContent?.content) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileContent.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy content:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center py-12', className)}>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'explorer.preview.loading' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'explorer.preview.errorTitle' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-md">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!fileContent) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
<File className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'explorer.preview.emptyTitle' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'explorer.preview.emptyMessage' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file is too large
|
||||
const isTooLarge = maxSize > 0 && (fileContent.size || 0) > maxSize;
|
||||
const isBinary = isBinaryFile(fileContent.path);
|
||||
|
||||
// Binary file warning
|
||||
if (isBinary) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
<File className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'explorer.preview.binaryTitle' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'explorer.preview.binaryMessage' })}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>{fileContent.path}</span>
|
||||
{fileContent.size && (
|
||||
<span className="ml-2">({formatFileSize(fileContent.size)})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File too large warning
|
||||
if (isTooLarge) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
<AlertCircle className="h-12 w-12 text-warning mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'explorer.preview.tooLargeTitle' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{formatMessage(
|
||||
{ id: 'explorer.preview.tooLargeMessage' },
|
||||
{ size: formatFileSize(maxSize) }
|
||||
)}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>{fileContent.path}</span>
|
||||
{fileContent.size && (
|
||||
<span className="ml-2">({formatFileSize(fileContent.size)})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get file name and extension
|
||||
const fileName = fileContent.path.split('/').pop() || '';
|
||||
const extension = getFileExtension(fileContent.path);
|
||||
const language = fileContent.language || getLanguageDisplayName(fileContent.language);
|
||||
const truncatedContent = truncateContent(fileContent.content);
|
||||
|
||||
// Split into lines for line numbers
|
||||
const lines = truncatedContent.split('\n');
|
||||
|
||||
return (
|
||||
<div className={cn('file-preview flex flex-col h-full', className)}>
|
||||
{/* Preview header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<File className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
{extension && (
|
||||
<span className="text-xs text-muted-foreground uppercase">.{extension}</span>
|
||||
)}
|
||||
{fileContent.size && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatFileSize(fileContent.size)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileContent.language && (
|
||||
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted">
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCopy}
|
||||
title={formatMessage({ id: 'explorer.preview.copy' })}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-auto custom-scrollbar">
|
||||
<pre
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
'text-sm p-4 m-0 bg-background',
|
||||
'font-mono leading-relaxed',
|
||||
'whitespace-pre-wrap break-words',
|
||||
'[&_::selection]:bg-primary/20 [&_::selection]:text-primary'
|
||||
)}
|
||||
>
|
||||
{showLineNumbers ? (
|
||||
<div className="flex">
|
||||
{/* Line numbers */}
|
||||
<div className="text-right text-muted-foreground select-none pr-4 border-r border-border mr-4 min-w-[3rem]">
|
||||
{lines.map((_, i) => (
|
||||
<div key={i} className="leading-relaxed">
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Code content */}
|
||||
<code className="flex-1">{truncatedContent}</code>
|
||||
</div>
|
||||
) : (
|
||||
<code>{truncatedContent}</code>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Footer with metadata */}
|
||||
{fileContent.modifiedTime && (
|
||||
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'explorer.preview.lastModified' },
|
||||
{ time: new Date(fileContent.modifiedTime).toLocaleString() }
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreview;
|
||||
257
ccw/frontend/src/components/shared/GraphSidebar.tsx
Normal file
257
ccw/frontend/src/components/shared/GraphSidebar.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
// ========================================
|
||||
// Graph Sidebar Component
|
||||
// ========================================
|
||||
// Sidebar with legend and node details for Graph Explorer
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, Info, Network, FileText, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { GraphNode, NodeType, EdgeType } from '@/types/graph-explorer';
|
||||
|
||||
export interface GraphSidebarProps {
|
||||
/** Selected node */
|
||||
selectedNode: GraphNode | null;
|
||||
/** Legend visibility */
|
||||
showLegend?: boolean;
|
||||
/** On close callback */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node type legend item
|
||||
*/
|
||||
interface LegendItem {
|
||||
type: NodeType;
|
||||
label: string;
|
||||
color: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph sidebar component
|
||||
*/
|
||||
export function GraphSidebar({ selectedNode, showLegend = true, onClose }: GraphSidebarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const legendItems: LegendItem[] = [
|
||||
{
|
||||
type: 'component',
|
||||
label: formatMessage({ id: 'graph.legend.component' }),
|
||||
color: 'bg-blue-500',
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
type: 'module',
|
||||
label: formatMessage({ id: 'graph.legend.module' }),
|
||||
color: 'bg-blue-500',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
type: 'class',
|
||||
label: formatMessage({ id: 'graph.legend.class' }),
|
||||
color: 'bg-green-500',
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
label: formatMessage({ id: 'graph.legend.function' }),
|
||||
color: 'bg-orange-500',
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
type: 'variable',
|
||||
label: formatMessage({ id: 'graph.legend.variable' }),
|
||||
color: 'bg-cyan-500',
|
||||
icon: Info,
|
||||
},
|
||||
];
|
||||
|
||||
const edgeLegendItems = [
|
||||
{
|
||||
type: 'imports' as EdgeType,
|
||||
label: formatMessage({ id: 'graph.legend.imports' }),
|
||||
color: 'stroke-gray-500',
|
||||
dashArray: '',
|
||||
},
|
||||
{
|
||||
type: 'calls' as EdgeType,
|
||||
label: formatMessage({ id: 'graph.legend.calls' }),
|
||||
color: 'stroke-green-500',
|
||||
dashArray: '',
|
||||
},
|
||||
{
|
||||
type: 'extends' as EdgeType,
|
||||
label: formatMessage({ id: 'graph.legend.extends' }),
|
||||
color: 'stroke-purple-500',
|
||||
dashArray: 'stroke-dasharray',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-card border-l border-border flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="font-semibold text-sm">
|
||||
{selectedNode
|
||||
? formatMessage({ id: 'graph.sidebar.nodeDetails' })
|
||||
: formatMessage({ id: 'graph.sidebar.title' })}
|
||||
</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Node details */}
|
||||
{selectedNode ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Node header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline">{selectedNode.type}</Badge>
|
||||
{selectedNode.data.hasIssues && (
|
||||
<Badge variant="destructive">
|
||||
{formatMessage({ id: 'graph.sidebar.hasIssues' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedNode.data.label}</h3>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
{selectedNode.data.filePath && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.filePath' })}
|
||||
</label>
|
||||
<p className="text-sm font-mono mt-1 break-all">{selectedNode.data.filePath}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line number */}
|
||||
{selectedNode.data.lineNumber && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.lineNumber' })}
|
||||
</label>
|
||||
<p className="text-sm mt-1">{selectedNode.data.lineNumber}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
{selectedNode.data.category && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.category' })}
|
||||
</label>
|
||||
<p className="text-sm mt-1 capitalize">{selectedNode.data.category}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line count */}
|
||||
{selectedNode.data.lineCount && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.lineCount' })}
|
||||
</label>
|
||||
<p className="text-sm mt-1">{selectedNode.data.lineCount} lines</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation */}
|
||||
{selectedNode.data.documentation && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.documentation' })}
|
||||
</label>
|
||||
<p className="text-sm mt-1 text-muted-foreground">{selectedNode.data.documentation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{selectedNode.data.tags && selectedNode.data.tags.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.tags' })}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{selectedNode.data.tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{selectedNode.data.issues && selectedNode.data.issues.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.issues' })}
|
||||
</label>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{selectedNode.data.issues.map((issue, idx) => (
|
||||
<li key={idx} className="text-sm text-red-600 dark:text-red-400">
|
||||
• {issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Node types legend */}
|
||||
{showLegend && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-3">
|
||||
{formatMessage({ id: 'graph.legend.nodeTypes' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{legendItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.type} className="flex items-center gap-2">
|
||||
<div className={cn('w-4 h-4 rounded', item.color)} />
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edge types legend */}
|
||||
{showLegend && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-3">
|
||||
{formatMessage({ id: 'graph.legend.edgeTypes' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{edgeLegendItems.map(item => (
|
||||
<div key={item.type} className="flex items-center gap-2">
|
||||
<div className={cn('w-8 h-0.5', item.color, item.dashArray)} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.sidebar.instructions' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
ccw/frontend/src/components/shared/GraphToolbar.tsx
Normal file
225
ccw/frontend/src/components/shared/GraphToolbar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
// ========================================
|
||||
// Graph Toolbar Component
|
||||
// ========================================
|
||||
// Toolbar with filters and actions for Graph Explorer
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Filter,
|
||||
Maximize,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { GraphFilters, NodeType, EdgeType } from '@/types/graph-explorer';
|
||||
|
||||
export interface GraphToolbarProps {
|
||||
/** Current filters */
|
||||
filters: GraphFilters;
|
||||
/** On filters change callback */
|
||||
onFiltersChange: (filters: GraphFilters) => void;
|
||||
/** On fit view callback */
|
||||
onFitView: () => void;
|
||||
/** On refresh callback */
|
||||
onRefresh: () => void;
|
||||
/** On reset filters callback */
|
||||
onResetFilters: () => void;
|
||||
/** Node type counts for badges */
|
||||
nodeTypeCounts?: Partial<Record<NodeType, number>>;
|
||||
/** Edge type counts for badges */
|
||||
edgeTypeCounts?: Partial<Record<EdgeType, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph toolbar component
|
||||
*/
|
||||
export function GraphToolbar({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onFitView,
|
||||
onRefresh,
|
||||
onResetFilters,
|
||||
nodeTypeCounts,
|
||||
edgeTypeCounts,
|
||||
}: GraphToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [localFilters, setLocalFilters] = useState<GraphFilters>(filters);
|
||||
|
||||
const nodeTypeLabels: Record<NodeType, string> = {
|
||||
component: formatMessage({ id: 'graph.nodeTypes.component' }),
|
||||
module: formatMessage({ id: 'graph.nodeTypes.module' }),
|
||||
function: formatMessage({ id: 'graph.nodeTypes.function' }),
|
||||
class: formatMessage({ id: 'graph.nodeTypes.class' }),
|
||||
interface: formatMessage({ id: 'graph.nodeTypes.interface' }),
|
||||
variable: formatMessage({ id: 'graph.nodeTypes.variable' }),
|
||||
file: formatMessage({ id: 'graph.nodeTypes.file' }),
|
||||
folder: formatMessage({ id: 'graph.nodeTypes.folder' }),
|
||||
dependency: formatMessage({ id: 'graph.nodeTypes.dependency' }),
|
||||
api: formatMessage({ id: 'graph.nodeTypes.api' }),
|
||||
database: formatMessage({ id: 'graph.nodeTypes.database' }),
|
||||
service: formatMessage({ id: 'graph.nodeTypes.service' }),
|
||||
hook: formatMessage({ id: 'graph.nodeTypes.hook' }),
|
||||
utility: formatMessage({ id: 'graph.nodeTypes.utility' }),
|
||||
unknown: formatMessage({ id: 'graph.nodeTypes.unknown' }),
|
||||
};
|
||||
|
||||
const edgeTypeLabels: Record<EdgeType, string> = {
|
||||
imports: formatMessage({ id: 'graph.edgeTypes.imports' }),
|
||||
exports: formatMessage({ id: 'graph.edgeTypes.exports' }),
|
||||
extends: formatMessage({ id: 'graph.edgeTypes.extends' }),
|
||||
implements: formatMessage({ id: 'graph.edgeTypes.implements' }),
|
||||
uses: formatMessage({ id: 'graph.edgeTypes.uses' }),
|
||||
'depends-on': formatMessage({ id: 'graph.edgeTypes.dependsOn' }),
|
||||
calls: formatMessage({ id: 'graph.edgeTypes.calls' }),
|
||||
instantiates: formatMessage({ id: 'graph.edgeTypes.instantiates' }),
|
||||
contains: formatMessage({ id: 'graph.edgeTypes.contains' }),
|
||||
'related-to': formatMessage({ id: 'graph.edgeTypes.relatedTo' }),
|
||||
'data-flow': formatMessage({ id: 'graph.edgeTypes.dataFlow' }),
|
||||
event: formatMessage({ id: 'graph.edgeTypes.event' }),
|
||||
unknown: formatMessage({ id: 'graph.edgeTypes.unknown' }),
|
||||
};
|
||||
|
||||
const handleNodeTypeToggle = (nodeType: NodeType) => {
|
||||
const current = localFilters.nodeTypes || [];
|
||||
const updated = current.includes(nodeType)
|
||||
? current.filter(t => t !== nodeType)
|
||||
: [...current, nodeType];
|
||||
const newFilters = { ...localFilters, nodeTypes: updated };
|
||||
setLocalFilters(newFilters);
|
||||
onFiltersChange(newFilters);
|
||||
};
|
||||
|
||||
const handleEdgeTypeToggle = (edgeType: EdgeType) => {
|
||||
const current = localFilters.edgeTypes || [];
|
||||
const updated = current.includes(edgeType)
|
||||
? current.filter(t => t !== edgeType)
|
||||
: [...current, edgeType];
|
||||
const newFilters = { ...localFilters, edgeTypes: updated };
|
||||
setLocalFilters(newFilters);
|
||||
onFiltersChange(newFilters);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
(localFilters.nodeTypes && localFilters.nodeTypes.length < Object.keys(nodeTypeLabels).length) ||
|
||||
(localFilters.edgeTypes && localFilters.edgeTypes.length < Object.keys(edgeTypeLabels).length) ||
|
||||
localFilters.searchQuery ||
|
||||
localFilters.showOnlyIssues;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 bg-card border-b border-border">
|
||||
{/* Node types filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{formatMessage({ id: 'graph.filters.nodeTypes' })}
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{localFilters.nodeTypes?.length || 0}
|
||||
</Badge>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'graph.filters.selectNodeTypes' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(nodeTypeLabels).map(([type, label]) => {
|
||||
const count = nodeTypeCounts?.[type as NodeType] || 0;
|
||||
const isChecked = localFilters.nodeTypes?.includes(type as NodeType);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={type}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleNodeTypeToggle(type as NodeType)}
|
||||
disabled={count === 0}
|
||||
>
|
||||
<span className="flex-1">{label}</span>
|
||||
{count > 0 && (
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edge types filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{formatMessage({ id: 'graph.filters.edgeTypes' })}
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{localFilters.edgeTypes?.length || 0}
|
||||
</Badge>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'graph.filters.selectEdgeTypes' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(edgeTypeLabels).map(([type, label]) => {
|
||||
const count = edgeTypeCounts?.[type as EdgeType] || 0;
|
||||
const isChecked = localFilters.edgeTypes?.includes(type as EdgeType);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={type}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => handleEdgeTypeToggle(type as EdgeType)}
|
||||
disabled={count === 0}
|
||||
>
|
||||
<span className="flex-1">{label}</span>
|
||||
{count > 0 && (
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* Zoom and view controls */}
|
||||
<Button variant="ghost" size="sm" onClick={onFitView} title={formatMessage({ id: 'graph.actions.fitView' })}>
|
||||
<Maximize className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* Actions */}
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} title={formatMessage({ id: 'graph.actions.refresh' })}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Reset filters button (only show when active filters) */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onResetFilters}
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
title={formatMessage({ id: 'graph.actions.resetFilters' })}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'graph.actions.reset' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
ccw/frontend/src/components/shared/IndexManager.tsx
Normal file
227
ccw/frontend/src/components/shared/IndexManager.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
// ========================================
|
||||
// IndexManager Component
|
||||
// ========================================
|
||||
// Component for managing code index with status display and rebuild functionality
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Database, RefreshCw, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useIndex } from '@/hooks/useIndex';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface IndexManagerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
/**
|
||||
* Progress bar for index rebuild
|
||||
*/
|
||||
function IndexProgressBar({ progress, status }: { progress?: number; status: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (status !== 'building' || progress === undefined) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'index.status.building' })}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge component
|
||||
*/
|
||||
function IndexStatusBadge({ status }: { status: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const config: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string }> = {
|
||||
idle: { variant: 'secondary', label: formatMessage({ id: 'index.status.idle' }) },
|
||||
building: { variant: 'default', label: formatMessage({ id: 'index.status.building' }) },
|
||||
completed: { variant: 'outline', label: formatMessage({ id: 'index.status.completed' }) },
|
||||
failed: { variant: 'destructive', label: formatMessage({ id: 'index.status.failed' }) },
|
||||
};
|
||||
|
||||
const { variant, label } = config[status] ?? config.idle;
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* IndexManager component for displaying index status and managing rebuild operations
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <IndexManager />
|
||||
* ```
|
||||
*/
|
||||
export function IndexManager({ className }: IndexManagerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { status, isLoading, rebuildIndex, isRebuilding, rebuildError, refetch } = useIndex();
|
||||
|
||||
// Auto-refresh during rebuild
|
||||
const refetchInterval = status?.status === 'building' ? 2000 : 0;
|
||||
React.useEffect(() => {
|
||||
if (status?.status === 'building') {
|
||||
const interval = setInterval(() => {
|
||||
refetch();
|
||||
}, refetchInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [status?.status, refetchInterval, refetch]);
|
||||
|
||||
// Handle rebuild button click
|
||||
const handleRebuild = async () => {
|
||||
try {
|
||||
await rebuildIndex({ force: false });
|
||||
} catch (error) {
|
||||
console.error('[IndexManager] Rebuild failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format build time (ms to human readable)
|
||||
const formatBuildTime = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
};
|
||||
|
||||
// Format last updated time
|
||||
const formatLastUpdated = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return formatMessage({ id: 'index.time.justNow' });
|
||||
if (diffMins < 60) return formatMessage({ id: 'index.time.minutesAgo' }, { value: diffMins });
|
||||
if (diffHours < 24) return formatMessage({ id: 'index.time.hoursAgo' }, { value: diffHours });
|
||||
return formatMessage({ id: 'index.time.daysAgo' }, { value: diffDays });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('p-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'index.title' })}
|
||||
</h2>
|
||||
{status && <IndexStatusBadge status={status.status} />}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRebuild}
|
||||
disabled={isRebuilding || status?.status === 'building'}
|
||||
className="h-8"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-1', isRebuilding && 'animate-spin')} />
|
||||
{formatMessage({ id: 'index.actions.rebuild' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'index.description' })}
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
{rebuildError && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{formatMessage({ id: 'index.errors.rebuildFailed' })}
|
||||
</p>
|
||||
<p className="text-xs text-destructive/80 mt-1">{rebuildError.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status error */}
|
||||
{status?.error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-destructive">{status.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{status && <IndexProgressBar progress={status.progress} status={status.status} />}
|
||||
|
||||
{/* Current file being indexed */}
|
||||
{status?.currentFile && status.status === 'building' && (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
<span className="truncate">{status.currentFile}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
{/* Total Files */}
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'index.stats.totalFiles' })}
|
||||
value={status?.totalFiles ?? 0}
|
||||
icon={Database}
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
description={formatMessage({ id: 'index.stats.totalFilesDesc' })}
|
||||
/>
|
||||
|
||||
{/* Last Updated */}
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'index.stats.lastUpdated' })}
|
||||
value={status?.lastUpdated ? formatLastUpdated(status.lastUpdated) : '-'}
|
||||
icon={Clock}
|
||||
variant="info"
|
||||
isLoading={isLoading}
|
||||
description={status?.lastUpdated
|
||||
? new Date(status.lastUpdated).toLocaleString()
|
||||
: formatMessage({ id: 'index.stats.never' })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Build Time */}
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'index.stats.buildTime' })}
|
||||
value={status?.buildTime ? formatBuildTime(status.buildTime) : '-'}
|
||||
icon={status?.status === 'completed' ? CheckCircle2 : AlertCircle}
|
||||
variant={status?.status === 'completed' ? 'success' : 'warning'}
|
||||
isLoading={isLoading}
|
||||
description={formatMessage({ id: 'index.stats.buildTimeDesc' })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexManager;
|
||||
311
ccw/frontend/src/components/shared/InsightsPanel.tsx
Normal file
311
ccw/frontend/src/components/shared/InsightsPanel.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
// ========================================
|
||||
// InsightsPanel Component
|
||||
// ========================================
|
||||
// AI insights panel for prompt history analysis
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Wand2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import type { PromptInsight, Pattern, Suggestion } from '@/types/store';
|
||||
|
||||
export interface InsightsPanelProps {
|
||||
/** Available insights */
|
||||
insights?: PromptInsight[];
|
||||
/** Detected patterns */
|
||||
patterns?: Pattern[];
|
||||
/** AI suggestions */
|
||||
suggestions?: Suggestion[];
|
||||
/** Currently selected tool */
|
||||
selectedTool: 'gemini' | 'qwen' | 'codex';
|
||||
/** Called when tool selection changes */
|
||||
onToolChange: (tool: 'gemini' | 'qwen' | 'codex') => void;
|
||||
/** Called when analyze is triggered */
|
||||
onAnalyze: () => void;
|
||||
/** Loading state */
|
||||
isAnalyzing?: boolean;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const toolConfig = {
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
qwen: {
|
||||
label: 'Qwen',
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* InsightCard component for displaying a single insight
|
||||
*/
|
||||
function InsightCard({ insight }: { insight: PromptInsight }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const typeConfig = {
|
||||
suggestion: {
|
||||
icon: Lightbulb,
|
||||
variant: 'info' as const,
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
optimization: {
|
||||
icon: Sparkles,
|
||||
variant: 'success' as const,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
variant: 'warning' as const,
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
};
|
||||
|
||||
const config = typeConfig[insight.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className={cn('flex-shrink-0', config.color)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">{insight.content}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{Math.round(insight.confidence * 100)}% {formatMessage({ id: 'prompts.insights.confidence' })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PatternCard component for displaying a detected pattern
|
||||
*/
|
||||
function PatternCard({ pattern }: { pattern: Pattern }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex-shrink-0 text-purple-500">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{pattern.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{pattern.description}</p>
|
||||
{pattern.example && (
|
||||
<code className="block mt-2 text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{pattern.example}
|
||||
</code>
|
||||
)}
|
||||
{pattern.severity && (
|
||||
<Badge variant={pattern.severity === 'error' ? 'destructive' : pattern.severity === 'warning' ? 'warning' : 'secondary'} className="mt-2 text-xs">
|
||||
{pattern.severity}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SuggestionCard component for displaying a suggestion
|
||||
*/
|
||||
function SuggestionCard({ suggestion }: { suggestion: Suggestion }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const typeConfig = {
|
||||
refactor: { color: 'text-blue-500', label: formatMessage({ id: 'prompts.suggestions.types.refactor' }) },
|
||||
optimize: { color: 'text-green-500', label: formatMessage({ id: 'prompts.suggestions.types.optimize' }) },
|
||||
fix: { color: 'text-orange-500', label: formatMessage({ id: 'prompts.suggestions.types.fix' }) },
|
||||
document: { color: 'text-purple-500', label: formatMessage({ id: 'prompts.suggestions.types.document' }) },
|
||||
};
|
||||
|
||||
const config = typeConfig[suggestion.type];
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className={cn('flex-shrink-0', config.color)}>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{suggestion.title}</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{suggestion.description}</p>
|
||||
{suggestion.code && (
|
||||
<code className="block mt-2 text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{suggestion.code}
|
||||
</code>
|
||||
)}
|
||||
{suggestion.effort && (
|
||||
<Badge variant="secondary" className="mt-2 text-xs">
|
||||
{formatMessage({ id: 'prompts.suggestions.effort' })}: {suggestion.effort}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InsightsPanel component - AI analysis panel for prompt history
|
||||
*/
|
||||
export function InsightsPanel({
|
||||
insights = [],
|
||||
patterns = [],
|
||||
suggestions = [],
|
||||
selectedTool,
|
||||
onToolChange,
|
||||
onAnalyze,
|
||||
isAnalyzing = false,
|
||||
className,
|
||||
}: InsightsPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const hasContent = insights.length > 0 || patterns.length > 0 || suggestions.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn('flex flex-col h-full', className)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
{formatMessage({ id: 'prompts.insights.title' })}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAnalyze()}
|
||||
disabled={isAnalyzing}
|
||||
className="gap-2"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{formatMessage({ id: 'prompts.insights.analyze' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tool selector */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.insights.selectTool' })}:
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{(Object.keys(toolConfig) as Array<keyof typeof toolConfig>).map((tool) => (
|
||||
<button
|
||||
key={tool}
|
||||
onClick={() => onToolChange(tool)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
selectedTool === tool
|
||||
? cn(toolConfig[tool].bgColor, toolConfig[tool].color)
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
>
|
||||
{toolConfig[tool].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{!hasContent && !isAnalyzing ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<Sparkles className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'prompts.insights.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
{formatMessage({ id: 'prompts.insights.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Insights section */}
|
||||
{insights.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
{formatMessage({ id: 'prompts.insights.sections.insights' })}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{insights.map((insight) => (
|
||||
<InsightCard key={insight.id} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patterns section */}
|
||||
{patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{formatMessage({ id: 'prompts.insights.sections.patterns' })}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{patterns.map((pattern) => (
|
||||
<PatternCard key={pattern.id} pattern={pattern} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions section */}
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{formatMessage({ id: 'prompts.insights.sections.suggestions' })}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<SuggestionCard key={suggestion.id} suggestion={suggestion} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.insights.analyzing' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsightsPanel;
|
||||
204
ccw/frontend/src/components/shared/PromptCard.tsx
Normal file
204
ccw/frontend/src/components/shared/PromptCard.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
// ========================================
|
||||
// PromptCard Component
|
||||
// ========================================
|
||||
// Card component for displaying prompt history items
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Copy,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Tag,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import type { Prompt } from '@/types/store';
|
||||
|
||||
export interface PromptCardProps {
|
||||
/** Prompt data */
|
||||
prompt: Prompt;
|
||||
/** Called when delete action is triggered */
|
||||
onDelete?: (id: string) => void;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
/** Disabled state for actions */
|
||||
actionsDisabled?: boolean;
|
||||
/** Default expanded state */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content length
|
||||
*/
|
||||
function formatContentLength(length: number): string {
|
||||
if (length >= 1000) {
|
||||
return `${(length / 1000).toFixed(1)}k chars`;
|
||||
}
|
||||
return `${length} chars`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptCard component for displaying prompt history items
|
||||
*/
|
||||
export function PromptCard({
|
||||
prompt,
|
||||
onDelete,
|
||||
className,
|
||||
actionsDisabled = false,
|
||||
defaultExpanded = false,
|
||||
}: PromptCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy prompt');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(prompt.id);
|
||||
};
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('transition-all duration-200', className)}>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Title and metadata */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{prompt.title || formatMessage({ id: 'prompts.card.untitled' })}
|
||||
</h3>
|
||||
{prompt.category && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{prompt.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(prompt.createdAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{formatContentLength(prompt.content.length)}
|
||||
</span>
|
||||
{prompt.useCount !== undefined && prompt.useCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatMessage({ id: 'prompts.card.used' }, { count: prompt.useCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{prompt.tags && prompt.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{prompt.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{prompt.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{prompt.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleCopy}
|
||||
disabled={actionsDisabled}
|
||||
title={formatMessage({ id: 'prompts.actions.copy' })}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span className="sr-only">{formatMessage({ id: 'prompts.actions.copy' })}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={actionsDisabled}
|
||||
title={formatMessage({ id: 'prompts.actions.delete' })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">{formatMessage({ id: 'prompts.actions.delete' })}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={toggleExpanded}
|
||||
title={expanded ? formatMessage({ id: 'prompts.actions.collapse' }) : formatMessage({ id: 'prompts.actions.expand' })}
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<span className="sr-only">{expanded ? 'Collapse' : 'Expand'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{copied && (
|
||||
<p className="text-xs text-success mt-2">
|
||||
{formatMessage({ id: 'prompts.actions.copied' })}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<pre className="text-sm whitespace-pre-wrap break-words text-foreground">
|
||||
{prompt.content}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default PromptCard;
|
||||
89
ccw/frontend/src/components/shared/PromptStats.tsx
Normal file
89
ccw/frontend/src/components/shared/PromptStats.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// ========================================
|
||||
// PromptStats Component
|
||||
// ========================================
|
||||
// Statistics display for prompt history
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { MessageSquare, FileType, Hash } from 'lucide-react';
|
||||
|
||||
export interface PromptStatsProps {
|
||||
/** Total number of prompts */
|
||||
totalCount: number;
|
||||
/** Average prompt length in characters */
|
||||
avgLength: number;
|
||||
/** Most common intent/category */
|
||||
topIntent: string | null;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptStats component - displays prompt history statistics
|
||||
*
|
||||
* Shows three key metrics:
|
||||
* - Total prompts: overall count of stored prompts
|
||||
* - Average length: mean character count across all prompts
|
||||
* - Top intent: most frequently used category
|
||||
*/
|
||||
export function PromptStats({
|
||||
totalCount,
|
||||
avgLength,
|
||||
topIntent,
|
||||
isLoading = false,
|
||||
}: PromptStatsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Format average length for display
|
||||
const formatLength = (length: number): string => {
|
||||
if (length >= 1000) {
|
||||
return `${(length / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return length.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'prompts.stats.totalCount' })}
|
||||
value={totalCount}
|
||||
icon={MessageSquare}
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
description={formatMessage({ id: 'prompts.stats.totalCountDesc' })}
|
||||
/>
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'prompts.stats.avgLength' })}
|
||||
value={formatLength(avgLength)}
|
||||
icon={FileType}
|
||||
variant="info"
|
||||
isLoading={isLoading}
|
||||
description={formatMessage({ id: 'prompts.stats.avgLengthDesc' })}
|
||||
/>
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'prompts.stats.topIntent' })}
|
||||
value={topIntent || formatMessage({ id: 'prompts.stats.noIntent' })}
|
||||
icon={Hash}
|
||||
variant="success"
|
||||
isLoading={isLoading}
|
||||
description={formatMessage({ id: 'prompts.stats.topIntentDesc' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for PromptStats
|
||||
*/
|
||||
export function PromptStatsSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PromptStats;
|
||||
229
ccw/frontend/src/components/shared/RuleCard.tsx
Normal file
229
ccw/frontend/src/components/shared/RuleCard.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
// ========================================
|
||||
// RuleCard Component
|
||||
// ========================================
|
||||
// Rule card with status badge and action menu
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
FileCode,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Folder,
|
||||
User,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import type { Rule } from '@/types/store';
|
||||
|
||||
export interface RuleCardProps {
|
||||
/** Rule data */
|
||||
rule: Rule;
|
||||
/** Called when edit action is triggered */
|
||||
onEdit?: (rule: Rule) => void;
|
||||
/** Called when delete action is triggered */
|
||||
onDelete?: (ruleId: string) => void;
|
||||
/** Called when toggle enabled is triggered */
|
||||
onToggle?: (ruleId: string, enabled: boolean) => void;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
/** Show actions dropdown */
|
||||
showActions?: boolean;
|
||||
/** Disabled state for actions */
|
||||
actionsDisabled?: boolean;
|
||||
}
|
||||
|
||||
// Severity variant configuration (without labels for i18n)
|
||||
const severityVariantConfig: Record<
|
||||
string,
|
||||
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info'; icon: React.ReactNode }
|
||||
> = {
|
||||
error: { variant: 'destructive' as const, icon: <AlertCircle className="h-3 w-3" /> },
|
||||
warning: { variant: 'warning' as const, icon: <AlertTriangle className="h-3 w-3" /> },
|
||||
info: { variant: 'info' as const, icon: <Info className="h-3 w-3" /> },
|
||||
};
|
||||
|
||||
// Severity label keys for i18n
|
||||
const severityLabelKeys: Record<string, string> = {
|
||||
error: 'rules.severity.error',
|
||||
warning: 'rules.severity.warning',
|
||||
info: 'rules.severity.info',
|
||||
};
|
||||
|
||||
/**
|
||||
* RuleCard component for displaying rule information
|
||||
*/
|
||||
export function RuleCard({
|
||||
rule,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
className,
|
||||
showActions = true,
|
||||
actionsDisabled = false,
|
||||
}: RuleCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const { variant: severityVariant, icon: severityIcon } = severityVariantConfig[rule.severity || 'info'] || {
|
||||
variant: 'default' as const,
|
||||
icon: null,
|
||||
};
|
||||
const severityLabel = rule.severity
|
||||
? formatMessage({ id: severityLabelKeys[rule.severity] })
|
||||
: null;
|
||||
|
||||
const locationIcon = rule.location === 'user' ? <User className="h-3 w-3" /> : <Folder className="h-3 w-3" />;
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
onToggle?.(rule.id, enabled);
|
||||
};
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: 'edit' | 'delete') => {
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
onEdit?.(rule);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.(rule.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all duration-200 hover:shadow-md hover:border-primary/30',
|
||||
!rule.enabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-card-foreground truncate">
|
||||
{rule.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 text-muted-foreground" title={formatMessage({ id: rule.location === 'user' ? 'rules.location.user' : 'rules.location.project' })}>
|
||||
{locationIcon}
|
||||
</div>
|
||||
</div>
|
||||
{rule.category && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{rule.category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{rule.enabled && rule.severity && (
|
||||
<Badge variant={severityVariant} className="gap-1">
|
||||
{severityIcon}
|
||||
{severityLabel}
|
||||
</Badge>
|
||||
)}
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={actionsDisabled}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'rules.actions.edit' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleAction(e, 'delete')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'rules.actions.delete' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{rule.description && (
|
||||
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
|
||||
{rule.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{rule.pattern && (
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<FileCode className="h-3.5 w-3.5" />
|
||||
{rule.pattern}
|
||||
</span>
|
||||
)}
|
||||
{rule.subdirectory && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
{rule.subdirectory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for RuleCard
|
||||
*/
|
||||
export function RuleCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card className={cn('animate-pulse', className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 rounded bg-muted" />
|
||||
<div className="mt-1 h-3 w-24 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-5 w-8 rounded-full bg-muted" />
|
||||
<div className="h-8 w-8 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="mt-3 h-4 w-full rounded bg-muted" />
|
||||
<div className="mt-2 flex gap-4">
|
||||
<div className="h-3 w-20 rounded bg-muted" />
|
||||
<div className="h-3 w-16 rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
479
ccw/frontend/src/components/shared/RuleDialog.tsx
Normal file
479
ccw/frontend/src/components/shared/RuleDialog.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
// ========================================
|
||||
// RuleDialog Component
|
||||
// ========================================
|
||||
// Add/Edit dialog for rule configuration
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
createRule,
|
||||
updateRule,
|
||||
type Rule,
|
||||
type RuleCreateInput,
|
||||
} from '@/lib/api';
|
||||
import { rulesKeys } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface RuleDialogProps {
|
||||
mode: 'add' | 'edit';
|
||||
rule?: Rule;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
interface RuleFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
category: string;
|
||||
severity: Rule['severity'];
|
||||
fileName: string;
|
||||
location: 'project' | 'user';
|
||||
subdirectory: string;
|
||||
content: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
fileName?: string;
|
||||
content?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
// ========== Categories ==========
|
||||
|
||||
const RULE_CATEGORIES = [
|
||||
'coding',
|
||||
'testing',
|
||||
'security',
|
||||
'architecture',
|
||||
'documentation',
|
||||
'performance',
|
||||
'workflow',
|
||||
'tooling',
|
||||
'general',
|
||||
];
|
||||
|
||||
const SEVERITY_LEVELS: Exclude<Rule['severity'], undefined>[] = ['error', 'warning', 'info'];
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function RuleDialog({
|
||||
mode,
|
||||
rule,
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
}: RuleDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<RuleFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
category: 'general',
|
||||
severity: 'info',
|
||||
fileName: '',
|
||||
location: 'project',
|
||||
subdirectory: '',
|
||||
content: '',
|
||||
pattern: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
// Initialize form from rule prop (edit mode)
|
||||
useEffect(() => {
|
||||
if (rule && mode === 'edit') {
|
||||
setFormData({
|
||||
name: rule.name,
|
||||
description: rule.description || '',
|
||||
enabled: rule.enabled,
|
||||
category: rule.category || 'general',
|
||||
severity: rule.severity || 'info',
|
||||
fileName: rule.path?.split(/[/\\]/).pop() || `${rule.name.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
location: rule.location || 'project',
|
||||
subdirectory: rule.subdirectory || '',
|
||||
content: '',
|
||||
pattern: rule.pattern || '',
|
||||
});
|
||||
} else {
|
||||
// Reset form for add mode
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
category: 'general',
|
||||
severity: 'info',
|
||||
fileName: '',
|
||||
location: 'project',
|
||||
subdirectory: '',
|
||||
content: '',
|
||||
pattern: '',
|
||||
});
|
||||
}
|
||||
setErrors({});
|
||||
}, [rule, mode, open]);
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: RuleCreateInput) => createRule(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rulesKeys.all });
|
||||
handleClose();
|
||||
onSave?.();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ ruleId, config }: { ruleId: string; config: Partial<Rule> }) =>
|
||||
updateRule(ruleId, config),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rulesKeys.all });
|
||||
handleClose();
|
||||
onSave?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleClose = () => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFieldChange = (
|
||||
field: keyof RuleFormData,
|
||||
value: string | boolean | Rule['severity']
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear error for this field
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-generate fileName from name if not set
|
||||
useEffect(() => {
|
||||
if (formData.name && !formData.fileName) {
|
||||
const generatedFileName = `${formData.name.toLowerCase().replace(/\s+/g, '-')}.md`;
|
||||
setFormData((prev) => ({ ...prev, fileName: generatedFileName }));
|
||||
}
|
||||
}, [formData.name, formData.fileName]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
// Name required
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = formatMessage({ id: 'rules.dialog.validation.nameRequired' });
|
||||
}
|
||||
|
||||
// File name required
|
||||
if (!formData.fileName.trim()) {
|
||||
newErrors.fileName = formatMessage({ id: 'rules.dialog.validation.fileNameRequired' });
|
||||
}
|
||||
|
||||
// File name must end with .md
|
||||
if (formData.fileName && !formData.fileName.endsWith('.md')) {
|
||||
newErrors.fileName = formatMessage({ id: 'rules.dialog.validation.fileNameMd' });
|
||||
}
|
||||
|
||||
// Location required
|
||||
if (!formData.location) {
|
||||
newErrors.location = formatMessage({ id: 'rules.dialog.validation.locationRequired' });
|
||||
}
|
||||
|
||||
// Content required for new rules
|
||||
if (mode === 'add' && !formData.content.trim()) {
|
||||
newErrors.content = formatMessage({ id: 'rules.dialog.validation.contentRequired' });
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
const input: RuleCreateInput = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
enabled: formData.enabled,
|
||||
category: formData.category || undefined,
|
||||
severity: formData.severity,
|
||||
fileName: formData.fileName.trim(),
|
||||
location: formData.location,
|
||||
subdirectory: formData.subdirectory.trim() || undefined,
|
||||
content: formData.content.trim(),
|
||||
pattern: formData.pattern.trim() || undefined,
|
||||
};
|
||||
createMutation.mutate(input);
|
||||
} else {
|
||||
updateMutation.mutate({
|
||||
ruleId: rule!.id,
|
||||
config: {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
enabled: formData.enabled,
|
||||
category: formData.category || undefined,
|
||||
severity: formData.severity,
|
||||
pattern: formData.pattern.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'add'
|
||||
? formatMessage({ id: 'rules.dialog.addTitle' })
|
||||
: formatMessage({ id: 'rules.dialog.editTitle' }, { name: rule?.name })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'rules.dialog.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.name' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'rules.dialog.form.namePlaceholder' })}
|
||||
error={!!errors.name}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.description' })}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFieldChange('description', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'rules.dialog.form.descriptionPlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.category' })}
|
||||
</label>
|
||||
<Select value={formData.category} onValueChange={(v) => handleFieldChange('category', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RULE_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.severity' })}
|
||||
</label>
|
||||
<Select
|
||||
value={formData.severity}
|
||||
onValueChange={(v) => handleFieldChange('severity', v as Rule['severity'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITY_LEVELS.map((sev) => (
|
||||
<SelectItem key={sev} value={sev}>
|
||||
{formatMessage({ id: `rules.severity.${sev}` })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* File Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.fileName' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.fileName}
|
||||
onChange={(e) => handleFieldChange('fileName', e.target.value)}
|
||||
placeholder="rule-name.md"
|
||||
error={!!errors.fileName}
|
||||
/>
|
||||
{errors.fileName && (
|
||||
<p className="text-sm text-destructive">{errors.fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.location' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="location"
|
||||
value="project"
|
||||
checked={formData.location === 'project'}
|
||||
onChange={(e) => handleFieldChange('location', e.target.value as 'project' | 'user')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{formatMessage({ id: 'rules.location.project' })}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="location"
|
||||
value="user"
|
||||
checked={formData.location === 'user'}
|
||||
onChange={(e) => handleFieldChange('location', e.target.value as 'project' | 'user')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{formatMessage({ id: 'rules.location.user' })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{errors.location && (
|
||||
<p className="text-sm text-destructive">{errors.location}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subdirectory */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.subdirectory' })}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.subdirectory}
|
||||
onChange={(e) => handleFieldChange('subdirectory', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'rules.dialog.form.subdirectoryPlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pattern */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.pattern' })}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.pattern}
|
||||
onChange={(e) => handleFieldChange('pattern', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'rules.dialog.form.patternPlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content (only for new rules) */}
|
||||
{mode === 'add' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'rules.dialog.form.content' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => handleFieldChange('content', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'rules.dialog.form.contentPlaceholder' })}
|
||||
className={cn(
|
||||
'flex min-h-[150px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
errors.content && 'border-destructive focus-visible:ring-destructive'
|
||||
)}
|
||||
/>
|
||||
{errors.content && (
|
||||
<p className="text-sm text-destructive">{errors.content}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => handleFieldChange('enabled', e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm font-medium text-foreground cursor-pointer">
|
||||
{formatMessage({ id: 'rules.dialog.form.enabled' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'rules.dialog.actions.saving' })
|
||||
: formatMessage({ id: 'common.actions.save' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default RuleDialog;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
|
||||
import type { ColorScheme, ThemeMode } from '@/lib/theme';
|
||||
@@ -16,6 +17,7 @@ import type { ColorScheme, ThemeMode } from '@/lib/theme';
|
||||
* - System dark mode detection
|
||||
*/
|
||||
export function ThemeSelector() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { colorScheme, resolvedTheme, setColorScheme, setTheme } = useTheme();
|
||||
|
||||
// Resolved mode is either 'light' or 'dark'
|
||||
@@ -48,7 +50,7 @@ export function ThemeSelector() {
|
||||
{/* Color Scheme Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
颜色主题
|
||||
{formatMessage({ id: 'theme.title.colorScheme' })}
|
||||
</h3>
|
||||
<div
|
||||
className="grid grid-cols-4 gap-3"
|
||||
@@ -60,7 +62,7 @@ export function ThemeSelector() {
|
||||
<button
|
||||
key={scheme.id}
|
||||
onClick={() => handleSchemeSelect(scheme.id)}
|
||||
aria-label={`选择${scheme.name}主题`}
|
||||
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: `theme.colorScheme.${scheme.id}` }) })}
|
||||
aria-selected={colorScheme === scheme.id}
|
||||
role="radio"
|
||||
className={`
|
||||
@@ -81,7 +83,7 @@ export function ThemeSelector() {
|
||||
/>
|
||||
{/* Label */}
|
||||
<span className="text-xs font-medium text-text text-center">
|
||||
{scheme.name}
|
||||
{formatMessage({ id: `theme.colorScheme.${scheme.id}` })}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -91,7 +93,7 @@ export function ThemeSelector() {
|
||||
{/* Theme Mode Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
明暗模式
|
||||
{formatMessage({ id: 'theme.title.themeMode' })}
|
||||
</h3>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3"
|
||||
@@ -102,7 +104,7 @@ export function ThemeSelector() {
|
||||
<button
|
||||
key={modeOption.id}
|
||||
onClick={() => handleModeSelect(modeOption.id)}
|
||||
aria-label={`选择${modeOption.name}模式`}
|
||||
aria-label={formatMessage({ id: 'theme.select.themeMode' }, { name: formatMessage({ id: `theme.themeMode.${modeOption.id}` }) })}
|
||||
aria-selected={mode === modeOption.id}
|
||||
role="radio"
|
||||
className={`
|
||||
@@ -121,7 +123,7 @@ export function ThemeSelector() {
|
||||
</span>
|
||||
{/* Label */}
|
||||
<span className="text-sm font-medium text-text">
|
||||
{modeOption.name}
|
||||
{formatMessage({ id: `theme.themeMode.${modeOption.id}` })}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -131,7 +133,7 @@ export function ThemeSelector() {
|
||||
{/* Current Theme Display */}
|
||||
<div className="p-3 rounded-lg bg-surface border border-border">
|
||||
<p className="text-xs text-text-secondary">
|
||||
当前主题: <span className="font-medium text-text">{getThemeName(colorScheme, mode)}</span>
|
||||
{formatMessage({ id: 'theme.current' }, { name: getThemeName(colorScheme, mode) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
298
ccw/frontend/src/components/shared/TreeView.tsx
Normal file
298
ccw/frontend/src/components/shared/TreeView.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// ========================================
|
||||
// TreeView Component
|
||||
// ========================================
|
||||
// Recursive tree view component for file explorer using native HTML details/summary
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronRight, File, Folder, FolderOpen, FileCode } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileSystemNode } from '@/types/file-explorer';
|
||||
|
||||
export interface TreeViewProps {
|
||||
/** Root nodes of the file tree */
|
||||
nodes: FileSystemNode[];
|
||||
/** Set of expanded directory paths */
|
||||
expandedPaths: Set<string>;
|
||||
/** Currently selected file path */
|
||||
selectedPath: string | null;
|
||||
/** Callback when node is clicked */
|
||||
onNodeClick?: (node: FileSystemNode) => void;
|
||||
/** Callback when node is double-clicked */
|
||||
onNodeDoubleClick?: (node: FileSystemNode) => void;
|
||||
/** Callback to toggle expanded state */
|
||||
onToggle?: (path: string) => void;
|
||||
/** Maximum depth to display (0 = unlimited) */
|
||||
maxDepth?: number;
|
||||
/** Current depth level */
|
||||
depth?: number;
|
||||
/** Whether to show file icons */
|
||||
showIcons?: boolean;
|
||||
/** Whether to show file sizes */
|
||||
showSizes?: boolean;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: FileSystemNode;
|
||||
level: number;
|
||||
expandedPaths: Set<string>;
|
||||
selectedPath: string | null;
|
||||
maxDepth?: number;
|
||||
showIcons?: boolean;
|
||||
showSizes?: boolean;
|
||||
onNodeClick?: (node: FileSystemNode) => void;
|
||||
onNodeDoubleClick?: (node: FileSystemNode) => void;
|
||||
onToggle?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on file extension
|
||||
*/
|
||||
function getFileIcon(fileName: string): React.ElementType {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
|
||||
const codeExtensions = ['ts', 'tsx', 'js', 'jsx', 'vue', 'svelte', 'py', 'rb', 'go', 'rs', 'java', 'cs', 'php', 'scala', 'kt'];
|
||||
const configExtensions = ['json', 'yaml', 'yml', 'toml', 'ini', 'conf', 'xml', 'config'];
|
||||
|
||||
if (codeExtensions.includes(ext || '')) {
|
||||
return FileCode;
|
||||
}
|
||||
if (configExtensions.includes(ext || '')) {
|
||||
return FileCode;
|
||||
}
|
||||
|
||||
return File;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size in human-readable format
|
||||
*/
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* TreeNode component - renders a single tree node with children
|
||||
*/
|
||||
function TreeNode({
|
||||
node,
|
||||
level,
|
||||
expandedPaths,
|
||||
selectedPath,
|
||||
maxDepth = 0,
|
||||
showIcons = true,
|
||||
showSizes = false,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onToggle,
|
||||
}: TreeNodeProps) {
|
||||
const isDirectory = node.type === 'directory';
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isSelected = selectedPath === node.path;
|
||||
const hasChildren = isDirectory && node.children && node.children.length > 0;
|
||||
const shouldShowChildren = isExpanded && hasChildren;
|
||||
const isAtMaxDepth = maxDepth > 0 && level >= maxDepth;
|
||||
|
||||
// Get icon component
|
||||
let Icon: React.ElementType = File;
|
||||
if (isDirectory) {
|
||||
Icon = isExpanded ? FolderOpen : Folder;
|
||||
} else if (showIcons) {
|
||||
Icon = getFileIcon(node.name);
|
||||
}
|
||||
|
||||
// Handle click
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onNodeClick?.(node);
|
||||
|
||||
// Toggle directories on click
|
||||
if (isDirectory && hasChildren) {
|
||||
onToggle?.(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle double click
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onNodeDoubleClick?.(node);
|
||||
};
|
||||
|
||||
// Handle key press for accessibility
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick(e as any);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'tree-node',
|
||||
isDirectory && 'tree-directory',
|
||||
isSelected && 'selected'
|
||||
)}
|
||||
role="treeitem"
|
||||
aria-expanded={isDirectory ? isExpanded : undefined}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{/* Node content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded-sm cursor-pointer transition-colors',
|
||||
'hover:bg-hover hover:text-foreground',
|
||||
isSelected && 'bg-primary/10 text-primary',
|
||||
'text-foreground text-sm'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
title={node.path}
|
||||
>
|
||||
{/* Expand/collapse chevron for directories */}
|
||||
{isDirectory && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 flex-shrink-0 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Folder/File icon */}
|
||||
{showIcons && (
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-4 w-4 flex-shrink-0',
|
||||
isDirectory
|
||||
? isExpanded
|
||||
? 'text-blue-500'
|
||||
: 'text-blue-400'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node name */}
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
|
||||
{/* CLAUDE.md indicator */}
|
||||
{node.hasClaudeMd && (
|
||||
<span
|
||||
className="ml-1 px-1.5 py-0.5 text-[10px] font-semibold rounded bg-purple-500/20 text-purple-500"
|
||||
title="Contains CLAUDE.md context"
|
||||
>
|
||||
MD
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* File size */}
|
||||
{showSizes && !isDirectory && node.size && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatFileSize(node.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recursive children */}
|
||||
{shouldShowChildren && !isAtMaxDepth && node.children && (
|
||||
<div className="tree-children" role="group">
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedPath={selectedPath}
|
||||
maxDepth={maxDepth}
|
||||
showIcons={showIcons}
|
||||
showSizes={showSizes}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TreeView component - displays file tree with expand/collapse
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TreeView
|
||||
* nodes={fileTree}
|
||||
* expandedPaths={expandedPaths}
|
||||
* selectedPath={selectedFile}
|
||||
* onNodeClick={(node) => console.log('Clicked:', node.path)}
|
||||
* onToggle={(path) => toggleExpanded(path)}
|
||||
* showIcons
|
||||
* showSizes
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function TreeView({
|
||||
nodes,
|
||||
expandedPaths,
|
||||
selectedPath,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onToggle,
|
||||
maxDepth = 0,
|
||||
depth = 0,
|
||||
showIcons = true,
|
||||
showSizes = false,
|
||||
className,
|
||||
}: TreeViewProps) {
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<Folder className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No files found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('tree-view', className)}
|
||||
role="tree"
|
||||
aria-label="File tree"
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
level={depth}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedPath={selectedPath}
|
||||
maxDepth={maxDepth}
|
||||
showIcons={showIcons}
|
||||
showSizes={showSizes}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TreeView;
|
||||
81
ccw/frontend/src/components/shared/index.ts
Normal file
81
ccw/frontend/src/components/shared/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// ========================================
|
||||
// Shared Components Index
|
||||
// ========================================
|
||||
// Centralized exports for all shared components
|
||||
|
||||
// Card components
|
||||
export { SessionCard, SessionCardSkeleton } from './SessionCard';
|
||||
export type { SessionCardProps } from './SessionCard';
|
||||
|
||||
export { ConversationCard } from './ConversationCard';
|
||||
export type { ConversationCardProps } from './ConversationCard';
|
||||
|
||||
export { IssueCard, IssueCardSkeleton } from './IssueCard';
|
||||
export type { IssueCardProps } from './IssueCard';
|
||||
|
||||
export { SkillCard, SkillCardSkeleton } from './SkillCard';
|
||||
export type { SkillCardProps } from './SkillCard';
|
||||
|
||||
export { StatCard, StatCardSkeleton } from './StatCard';
|
||||
export type { StatCardProps } from './StatCard';
|
||||
|
||||
export { RuleCard } from './RuleCard';
|
||||
export type { RuleCardProps } from './RuleCard';
|
||||
|
||||
export { PromptCard } from './PromptCard';
|
||||
export type { PromptCardProps } from './PromptCard';
|
||||
|
||||
// Tree and file explorer components
|
||||
export { TreeView } from './TreeView';
|
||||
export type { TreeViewProps } from './TreeView';
|
||||
|
||||
export { FilePreview } from './FilePreview';
|
||||
export type { FilePreviewProps } from './FilePreview';
|
||||
|
||||
// Graph visualization components
|
||||
export { GraphToolbar } from './GraphToolbar';
|
||||
export type { GraphToolbarProps } from './GraphToolbar';
|
||||
|
||||
export { GraphSidebar } from './GraphSidebar';
|
||||
export type { GraphSidebarProps } from './GraphSidebar';
|
||||
|
||||
// Insights and analysis components
|
||||
export { InsightsPanel } from './InsightsPanel';
|
||||
export type { InsightsPanelProps } from './InsightsPanel';
|
||||
|
||||
export { PromptStats } from './PromptStats';
|
||||
export type { PromptStatsProps } from './PromptStats';
|
||||
|
||||
// Workflow and task components
|
||||
export { KanbanBoard } from './KanbanBoard';
|
||||
export type { KanbanBoardProps } from './KanbanBoard';
|
||||
|
||||
export { TaskDrawer } from './TaskDrawer';
|
||||
export type { TaskDrawerProps } from './TaskDrawer';
|
||||
|
||||
export { Flowchart } from './Flowchart';
|
||||
export type { FlowchartProps } from './Flowchart';
|
||||
|
||||
// CLI and streaming components
|
||||
export { CliStreamPanel } from './CliStreamPanel';
|
||||
export type { CliStreamPanelProps } from './CliStreamPanel';
|
||||
|
||||
export { CliStreamMonitor } from './CliStreamMonitor';
|
||||
export type { CliStreamMonitorProps } from './CliStreamMonitor';
|
||||
|
||||
export { StreamingOutput } from './StreamingOutput';
|
||||
export type { StreamingOutputProps } from './StreamingOutput';
|
||||
|
||||
// Dialog components
|
||||
export { RuleDialog } from './RuleDialog';
|
||||
export type { RuleDialogProps } from './RuleDialog';
|
||||
|
||||
// Tools and utility components
|
||||
export { ThemeSelector } from './ThemeSelector';
|
||||
export type { ThemeSelectorProps } from './ThemeSelector';
|
||||
|
||||
export { IndexManager } from './IndexManager';
|
||||
export type { IndexManagerProps } from './IndexManager';
|
||||
|
||||
export { ExplorerToolbar } from './ExplorerToolbar';
|
||||
export type { ExplorerToolbarProps } from './ExplorerToolbar';
|
||||
27
ccw/frontend/src/components/ui/Checkbox.tsx
Normal file
27
ccw/frontend/src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
23
ccw/frontend/src/components/ui/Label.tsx
Normal file
23
ccw/frontend/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
25
ccw/frontend/src/components/ui/Progress.tsx
Normal file
25
ccw/frontend/src/components/ui/Progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
50
ccw/frontend/src/components/ui/Switch.tsx
Normal file
50
ccw/frontend/src/components/ui/Switch.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// ========================================
|
||||
// Switch Component
|
||||
// ========================================
|
||||
// Toggle switch for boolean values
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
/** Checked state */
|
||||
checked?: boolean;
|
||||
/** Change handler */
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch component - a stylable toggle switch
|
||||
*/
|
||||
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, onChange, disabled = false, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e);
|
||||
onCheckedChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={cn('relative inline-flex items-center cursor-pointer', className)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn(
|
||||
'w-9 h-5 bg-input rounded-full peer peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring peer-focus:ring-offset-2',
|
||||
'peer-checked:bg-primary peer-checked:after:translate-x-full',
|
||||
'after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:rounded-full after:h-4 after:w-4 after:transition-all',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export default Switch;
|
||||
26
ccw/frontend/src/components/ui/Textarea.tsx
Normal file
26
ccw/frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
277
ccw/frontend/src/components/workspace/WorkspaceSelector.tsx
Normal file
277
ccw/frontend/src/components/workspace/WorkspaceSelector.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
// ========================================
|
||||
// Workspace Selector Component
|
||||
// ========================================
|
||||
// Dropdown for selecting recent workspaces with manual path input dialog
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
|
||||
export interface WorkspaceSelectorProps {
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate path to maximum length with ellipsis prefix
|
||||
* Shows ".../last/folder" for paths longer than maxChars
|
||||
*/
|
||||
function truncatePath(path: string, maxChars: number = 40): string {
|
||||
if (path.length <= maxChars) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// For Windows paths: C:\Users\...\folder
|
||||
// For Unix paths: /home/user/.../folder
|
||||
const separator = path.includes('\\') ? '\\' : '/';
|
||||
const parts = path.split(separator);
|
||||
|
||||
// Start from the end and build up until we hit the limit
|
||||
const result: string[] = [];
|
||||
let currentLength = 3; // Start with '...' length
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
|
||||
const newLength = currentLength + part.length + 1;
|
||||
if (newLength > maxChars && result.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
result.unshift(part);
|
||||
currentLength = newLength;
|
||||
}
|
||||
|
||||
return '...' + separator + result.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace selector component
|
||||
*
|
||||
* Provides a dropdown menu for selecting from recent workspace paths,
|
||||
* a manual path input dialog for entering custom paths, and delete buttons
|
||||
* for removing paths from recent history.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <WorkspaceSelector />
|
||||
* ```
|
||||
*/
|
||||
export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore((state) => state.projectPath);
|
||||
const recentPaths = useWorkflowStore((state) => state.recentPaths);
|
||||
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
|
||||
const removeRecentPath = useWorkflowStore((state) => state.removeRecentPath);
|
||||
|
||||
// UI state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isBrowseOpen, setIsBrowseOpen] = useState(false);
|
||||
const [manualPath, setManualPath] = useState('');
|
||||
|
||||
/**
|
||||
* Handle path selection from dropdown
|
||||
*/
|
||||
const handleSelectPath = useCallback(
|
||||
async (path: string) => {
|
||||
await switchWorkspace(path);
|
||||
setIsDropdownOpen(false);
|
||||
},
|
||||
[switchWorkspace]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle remove path from recent history
|
||||
*/
|
||||
const handleRemovePath = useCallback(
|
||||
async (e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation(); // Prevent triggering selection
|
||||
await removeRecentPath(path);
|
||||
},
|
||||
[removeRecentPath]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle open browse dialog
|
||||
*/
|
||||
const handleBrowseFolder = useCallback(() => {
|
||||
setIsBrowseOpen(true);
|
||||
setIsDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle manual path submission
|
||||
*/
|
||||
const handleManualPathSubmit = useCallback(async () => {
|
||||
const trimmedPath = manualPath.trim();
|
||||
if (!trimmedPath) {
|
||||
return; // TODO: Show validation error
|
||||
}
|
||||
|
||||
await switchWorkspace(trimmedPath);
|
||||
setIsBrowseOpen(false);
|
||||
setManualPath('');
|
||||
}, [manualPath, switchWorkspace]);
|
||||
|
||||
/**
|
||||
* Handle dialog cancel
|
||||
*/
|
||||
const handleDialogCancel = useCallback(() => {
|
||||
setIsBrowseOpen(false);
|
||||
setManualPath('');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle keyboard events in dialog input
|
||||
*/
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleManualPathSubmit();
|
||||
}
|
||||
},
|
||||
[handleManualPathSubmit]
|
||||
);
|
||||
|
||||
const displayPath = projectPath || formatMessage({ id: 'workspace.selector.noWorkspace' });
|
||||
const truncatedPath = truncatePath(displayPath, 40);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('gap-2 max-w-[300px]', className)}
|
||||
aria-label={formatMessage({ id: 'workspace.selector.ariaLabel' })}
|
||||
>
|
||||
<span className="truncate" title={displayPath}>
|
||||
{truncatedPath}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-80">
|
||||
<DropdownMenuLabel>
|
||||
{formatMessage({ id: 'workspace.selector.recentPaths' })}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{recentPaths.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{recentPaths.length === 0 ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'workspace.selector.noRecentPaths' })}
|
||||
</div>
|
||||
) : (
|
||||
recentPaths.map((path) => {
|
||||
const isCurrent = path === projectPath;
|
||||
const truncatedItemPath = truncatePath(path, 50);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={path}
|
||||
onClick={() => handleSelectPath(path)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 cursor-pointer group',
|
||||
isCurrent && 'bg-accent'
|
||||
)}
|
||||
title={path}
|
||||
>
|
||||
<span className="flex-1 truncate">{truncatedItemPath}</span>
|
||||
|
||||
{/* Delete button for non-current paths */}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={(e) => handleRemovePath(e, path)}
|
||||
className="opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground rounded p-0.5 transition-opacity"
|
||||
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||
title={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<span className="text-xs text-primary">
|
||||
{formatMessage({ id: 'workspace.selector.current' })}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{recentPaths.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Browse button to open manual path dialog */}
|
||||
<DropdownMenuItem
|
||||
onClick={handleBrowseFolder}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{formatMessage({ id: 'workspace.selector.browse' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Manual path input dialog */}
|
||||
<Dialog open={isBrowseOpen} onOpenChange={setIsBrowseOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'workspace.selector.dialog.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={manualPath}
|
||||
onChange={(e) => setManualPath(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={formatMessage({ id: 'workspace.selector.dialog.placeholder' })}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDialogCancel}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManualPathSubmit}
|
||||
disabled={!manualPath.trim()}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.submit' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSelector;
|
||||
6
ccw/frontend/src/components/workspace/index.ts
Normal file
6
ccw/frontend/src/components/workspace/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ========================================
|
||||
// Workspace Components
|
||||
// ========================================
|
||||
|
||||
export { WorkspaceSelector } from './WorkspaceSelector';
|
||||
export type { WorkspaceSelectorProps } from './WorkspaceSelector';
|
||||
Reference in New Issue
Block a user