mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
feat: add injection preview functionality and enhance specs management
- Implemented injection preview feature in InjectionControlTab with file listing and content preview. - Added new API endpoint for fetching injection preview data. - Introduced content length caching for performance optimization. - Enhanced spec loading to support category filtering. - Updated localization files for new features and terms. - Created new personal and project specs for coding style and architecture constraints. - Improved CLI options for category selection in spec commands.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { Settings, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
@@ -113,6 +114,7 @@ const settingsKeys = {
|
||||
// ========== Component ==========
|
||||
|
||||
export function GlobalSettingsTab() {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local state for immediate UI feedback
|
||||
@@ -149,10 +151,13 @@ export function GlobalSettingsTab() {
|
||||
mutationFn: updateSystemSettings,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(settingsKeys.settings(), data.settings);
|
||||
toast.success('Settings saved successfully');
|
||||
toast.success(formatMessage({ id: 'specs.injection.saveSuccess', defaultMessage: 'Settings saved successfully' }));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
toast.error(formatMessage(
|
||||
{ id: 'specs.injection.saveError', defaultMessage: 'Failed to save settings: {error}' },
|
||||
{ error: error.message }
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,12 +199,6 @@ export function GlobalSettingsTab() {
|
||||
const isLoading = isLoadingSettings || isLoadingStats;
|
||||
const hasError = settingsError || statsError;
|
||||
|
||||
// Dimension display config
|
||||
const dimensionLabels: Record<string, string> = {
|
||||
specs: 'Specs',
|
||||
personal: 'Personal',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Spec Defaults Card */}
|
||||
@@ -207,16 +206,20 @@ export function GlobalSettingsTab() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Personal Spec Defaults</CardTitle>
|
||||
<CardTitle>
|
||||
{formatMessage({ id: 'specs.settings.personalSpecDefaults', defaultMessage: 'Personal Spec Defaults' })}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
These settings will be applied when creating new personal specs
|
||||
{formatMessage({ id: 'specs.settings.personalSpecDefaultsDesc', defaultMessage: 'These settings will be applied when creating new personal specs' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Default Read Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-read-mode">Default Read Mode</Label>
|
||||
<Label htmlFor="default-read-mode">
|
||||
{formatMessage({ id: 'specs.settings.defaultReadMode', defaultMessage: 'Default Read Mode' })}
|
||||
</Label>
|
||||
<Select
|
||||
value={localDefaults.defaultReadMode}
|
||||
onValueChange={(value) =>
|
||||
@@ -224,28 +227,30 @@ export function GlobalSettingsTab() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="default-read-mode" className="w-full">
|
||||
<SelectValue placeholder="Select read mode" />
|
||||
<SelectValue placeholder={formatMessage({ id: 'specs.settings.selectReadMode', defaultMessage: 'Select read mode' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="required">
|
||||
Required (Always inject)
|
||||
{formatMessage({ id: 'specs.readMode.required', defaultMessage: 'Required' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="optional">
|
||||
Optional (Inject on keyword match)
|
||||
{formatMessage({ id: 'specs.readMode.optional', defaultMessage: 'Optional' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The default read mode for newly created personal specs
|
||||
{formatMessage({ id: 'specs.settings.defaultReadModeHelp', defaultMessage: 'The default read mode for newly created personal specs' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Enable */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-enable">Auto Enable New Specs</Label>
|
||||
<Label htmlFor="auto-enable">
|
||||
{formatMessage({ id: 'specs.settings.autoEnable', defaultMessage: 'Auto Enable New Specs' })}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically enable newly created personal specs
|
||||
{formatMessage({ id: 'specs.settings.autoEnableDescription', defaultMessage: 'Automatically enable newly created personal specs' })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -262,7 +267,9 @@ export function GlobalSettingsTab() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Spec Statistics</CardTitle>
|
||||
<CardTitle>
|
||||
{formatMessage({ id: 'specs.settings.specStatistics', defaultMessage: 'Spec Statistics' })}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -280,7 +287,7 @@ export function GlobalSettingsTab() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -293,11 +300,11 @@ export function GlobalSettingsTab() {
|
||||
</div>
|
||||
) : hasError ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Failed to load statistics
|
||||
{formatMessage({ id: 'specs.injection.loadError', defaultMessage: 'Failed to load statistics' })}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{dimensionEntries.map(([dim, data]) => (
|
||||
<div
|
||||
key={dim}
|
||||
@@ -306,11 +313,11 @@ export function GlobalSettingsTab() {
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{data.count}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground capitalize">
|
||||
{dimensionLabels[dim] || dim}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: `specs.dimension.${dim}`, defaultMessage: dim })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{data.requiredCount} required
|
||||
{data.requiredCount} {formatMessage({ id: 'specs.required', defaultMessage: 'required' })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -319,10 +326,15 @@ export function GlobalSettingsTab() {
|
||||
{/* Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Total: {totalCount} spec files</span>
|
||||
<span>
|
||||
{totalRequired} required | {totalCount - totalRequired}{' '}
|
||||
optional
|
||||
{formatMessage(
|
||||
{ id: 'specs.settings.totalSpecs', defaultMessage: 'Total: {count} spec files' },
|
||||
{ count: totalCount }
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{totalRequired} {formatMessage({ id: 'specs.readMode.required', defaultMessage: 'required' })} | {totalCount - totalRequired}{' '}
|
||||
{formatMessage({ id: 'specs.readMode.optional', defaultMessage: 'optional' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,12 @@ import { useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -20,6 +26,7 @@ import { Label } from '@/components/ui/Label';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
AlertCircle,
|
||||
Info,
|
||||
@@ -30,8 +37,16 @@ import {
|
||||
Download,
|
||||
CheckCircle2,
|
||||
Settings,
|
||||
FileText,
|
||||
Eye,
|
||||
Globe,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings';
|
||||
import type { InjectionPreviewFile, InjectionPreviewResponse } from '@/lib/api';
|
||||
import { getInjectionPreview } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -167,6 +182,17 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
// State for hooks installation
|
||||
const [installingHookIds, setInstallingHookIds] = useState<string[]>([]);
|
||||
|
||||
// State for injection preview
|
||||
const [previewMode, setPreviewMode] = useState<'required' | 'all'>('required');
|
||||
const [previewData, setPreviewData] = useState<InjectionPreviewResponse | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [expandedDimensions, setExpandedDimensions] = useState<Record<string, boolean>>({
|
||||
specs: true,
|
||||
personal: true,
|
||||
});
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<InjectionPreviewFile | null>(null);
|
||||
|
||||
// Fetch stats
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
@@ -195,12 +221,44 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load injection preview
|
||||
const loadPreview = useCallback(async () => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const data = await getInjectionPreview(previewMode, false);
|
||||
setPreviewData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load injection preview:', err);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [previewMode]);
|
||||
|
||||
// Load file content for preview
|
||||
const loadFilePreview = useCallback(async (file: InjectionPreviewFile) => {
|
||||
try {
|
||||
const data = await getInjectionPreview(previewMode, true);
|
||||
const fileWithData = data.files.find(f => f.file === file.file);
|
||||
if (fileWithData) {
|
||||
setPreviewFile(fileWithData);
|
||||
setPreviewDialogOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load file preview:', err);
|
||||
}
|
||||
}, [previewMode]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadSettings();
|
||||
}, [loadStats, loadSettings]);
|
||||
|
||||
// Load preview when mode changes
|
||||
useEffect(() => {
|
||||
loadPreview();
|
||||
}, [loadPreview]);
|
||||
|
||||
// Check for changes
|
||||
useEffect(() => {
|
||||
const changed =
|
||||
@@ -245,20 +303,21 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
// Toggle dimension expansion
|
||||
const toggleDimension = (dim: string) => {
|
||||
setExpandedDimensions(prev => ({ ...prev, [dim]: !prev[dim] }));
|
||||
};
|
||||
|
||||
// ========== Hooks Installation ==========
|
||||
|
||||
// Get installed hooks from system settings
|
||||
const installedHookIds = useMemo(() => {
|
||||
const installed = new Set<string>();
|
||||
// Check if hooks are already installed by checking system settings
|
||||
// For now, we'll track this via the mutation result
|
||||
return installed;
|
||||
}, []);
|
||||
|
||||
const installedCount = 0; // Will be updated when we have real data
|
||||
const installedCount = 0;
|
||||
const allHooksInstalled = installedCount === RECOMMENDED_HOOKS.length;
|
||||
|
||||
// Install single hook
|
||||
const handleInstallHook = useCallback(async (hookId: string) => {
|
||||
setInstallingHookIds(prev => [...prev, hookId]);
|
||||
try {
|
||||
@@ -276,7 +335,6 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
}
|
||||
}, [installHooksMutation, formatMessage]);
|
||||
|
||||
// Install all hooks
|
||||
const handleInstallAllHooks = useCallback(async () => {
|
||||
const uninstalledHooks = RECOMMENDED_HOOKS.filter(h => !installedHookIds.has(h.id));
|
||||
if (uninstalledHooks.length === 0) return;
|
||||
@@ -300,6 +358,19 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
}
|
||||
}, [installedHookIds, installHooksMutation, formatMessage]);
|
||||
|
||||
// Group files by dimension
|
||||
const filesByDimension = useMemo(() => {
|
||||
if (!previewData) return {};
|
||||
const grouped: Record<string, InjectionPreviewFile[]> = {};
|
||||
for (const file of previewData.files) {
|
||||
if (!grouped[file.dimension]) {
|
||||
grouped[file.dimension] = [];
|
||||
}
|
||||
grouped[file.dimension].push(file);
|
||||
}
|
||||
return grouped;
|
||||
}, [previewData]);
|
||||
|
||||
// Calculate progress and status
|
||||
const currentLength = stats?.injectionLength?.withKeywords || 0;
|
||||
const maxLength = settings.maxLength;
|
||||
@@ -416,7 +487,7 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={loadStats}
|
||||
onClick={() => { loadStats(); loadPreview(); }}
|
||||
disabled={statsLoading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} />
|
||||
@@ -537,6 +608,116 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Injection Files List Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{formatMessage({ id: 'specs.injection.filesList', defaultMessage: 'Injection Files' })}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={previewMode === 'required' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('required')}
|
||||
>
|
||||
{formatMessage({ id: 'specs.readMode.required', defaultMessage: 'Required' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('all')}
|
||||
>
|
||||
{formatMessage({ id: 'specs.scope.all', defaultMessage: 'All' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{previewData && (
|
||||
<span>
|
||||
{previewData.stats.count} {formatMessage({ id: 'specs.injection.files', defaultMessage: 'files' })} • {formatNumber(previewData.stats.totalLength)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{previewLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] overflow-auto">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(filesByDimension).map(([dim, files]) => (
|
||||
<div key={dim} className="border rounded-lg">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
onClick={() => toggleDimension(dim)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedDimensions[dim] ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium capitalize">
|
||||
{formatMessage({ id: `specs.dimension.${dim}`, defaultMessage: dim })}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{files.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatNumber(files.reduce((sum, f) => sum + f.contentLength, 0))} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'chars' })}
|
||||
</span>
|
||||
</button>
|
||||
{expandedDimensions[dim] && (
|
||||
<div className="border-t">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.file}
|
||||
className="flex items-center justify-between p-3 border-b last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{file.scope === 'global' ? (
|
||||
<Globe className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{file.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{file.file}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage({ id: `specs.priority.${file.priority}`, defaultMessage: file.priority })}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatNumber(file.contentLength)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => loadFilePreview(file)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Settings Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -645,6 +826,23 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Preview Dialog */}
|
||||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{previewFile?.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<pre className="text-sm whitespace-pre-wrap p-4 bg-muted rounded-lg">
|
||||
{previewFile?.content || formatMessage({ id: 'specs.content.noContent', defaultMessage: 'No content available' })}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user