1043 lines
44 KiB
TypeScript
1043 lines
44 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
ArrowLeft,
|
|
Plus,
|
|
Trash2,
|
|
Save,
|
|
AlertCircle,
|
|
Loader2,
|
|
BarChart3,
|
|
Shield,
|
|
Trash,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import {
|
|
api,
|
|
type ClaudeSettings,
|
|
type ClaudeInstallation
|
|
} from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
import { Toast, ToastContainer } from "@/components/ui/toast";
|
|
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
|
import { StorageTab } from "./StorageTab";
|
|
import { HooksEditor } from "./HooksEditor";
|
|
import { SlashCommandsManager } from "./SlashCommandsManager";
|
|
import { ProxySettings } from "./ProxySettings";
|
|
import { AnalyticsConsent } from "./AnalyticsConsent";
|
|
import { useTheme, useTrackEvent, useTranslation } from "@/hooks";
|
|
import { analytics } from "@/lib/analytics";
|
|
|
|
interface SettingsProps {
|
|
/**
|
|
* Callback to go back to the main view
|
|
*/
|
|
onBack: () => void;
|
|
/**
|
|
* Optional className for styling
|
|
*/
|
|
className?: string;
|
|
}
|
|
|
|
interface PermissionRule {
|
|
id: string;
|
|
value: string;
|
|
}
|
|
|
|
interface EnvironmentVariable {
|
|
id: string;
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
/**
|
|
* Comprehensive Settings UI for managing Claude Code settings
|
|
* Provides a no-code interface for editing the settings.json file
|
|
*/
|
|
export const Settings: React.FC<SettingsProps> = ({
|
|
onBack,
|
|
className,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [settings, setSettings] = useState<ClaudeSettings | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState("general");
|
|
const [currentBinaryPath, setCurrentBinaryPath] = useState<string | null>(null);
|
|
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
|
const [binaryPathChanged, setBinaryPathChanged] = useState(false);
|
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
|
|
|
// Permission rules state
|
|
const [allowRules, setAllowRules] = useState<PermissionRule[]>([]);
|
|
const [denyRules, setDenyRules] = useState<PermissionRule[]>([]);
|
|
|
|
// Environment variables state
|
|
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
|
|
|
// Hooks state
|
|
const [userHooksChanged, setUserHooksChanged] = useState(false);
|
|
const getUserHooks = React.useRef<(() => any) | null>(null);
|
|
|
|
// Theme hook
|
|
const { theme, setTheme, customColors, setCustomColors } = useTheme();
|
|
|
|
// Proxy state
|
|
const [proxySettingsChanged, setProxySettingsChanged] = useState(false);
|
|
const saveProxySettings = React.useRef<(() => Promise<void>) | null>(null);
|
|
|
|
// Analytics state
|
|
const [analyticsEnabled, setAnalyticsEnabled] = useState(false);
|
|
const [analyticsConsented, setAnalyticsConsented] = useState(false);
|
|
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false);
|
|
const trackEvent = useTrackEvent();
|
|
|
|
// Load settings on mount
|
|
useEffect(() => {
|
|
loadSettings();
|
|
loadClaudeBinaryPath();
|
|
loadAnalyticsSettings();
|
|
}, []);
|
|
|
|
/**
|
|
* Loads analytics settings
|
|
*/
|
|
const loadAnalyticsSettings = async () => {
|
|
const settings = analytics.getSettings();
|
|
if (settings) {
|
|
setAnalyticsEnabled(settings.enabled);
|
|
setAnalyticsConsented(settings.hasConsented);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Loads the current Claude binary path
|
|
*/
|
|
const loadClaudeBinaryPath = async () => {
|
|
try {
|
|
const path = await api.getClaudeBinaryPath();
|
|
setCurrentBinaryPath(path);
|
|
} catch (err) {
|
|
console.error("Failed to load Claude binary path:", err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Loads the current Claude settings
|
|
*/
|
|
const loadSettings = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const loadedSettings = await api.getClaudeSettings();
|
|
|
|
// Ensure loadedSettings is an object
|
|
if (!loadedSettings || typeof loadedSettings !== 'object') {
|
|
console.warn("Loaded settings is not an object:", loadedSettings);
|
|
setSettings({});
|
|
return;
|
|
}
|
|
|
|
setSettings(loadedSettings);
|
|
|
|
// Parse permissions
|
|
if (loadedSettings.permissions && typeof loadedSettings.permissions === 'object') {
|
|
if (Array.isArray(loadedSettings.permissions.allow)) {
|
|
setAllowRules(
|
|
loadedSettings.permissions.allow.map((rule: string, index: number) => ({
|
|
id: `allow-${index}`,
|
|
value: rule,
|
|
}))
|
|
);
|
|
}
|
|
if (Array.isArray(loadedSettings.permissions.deny)) {
|
|
setDenyRules(
|
|
loadedSettings.permissions.deny.map((rule: string, index: number) => ({
|
|
id: `deny-${index}`,
|
|
value: rule,
|
|
}))
|
|
);
|
|
}
|
|
}
|
|
|
|
// Parse environment variables
|
|
if (loadedSettings.env && typeof loadedSettings.env === 'object' && !Array.isArray(loadedSettings.env)) {
|
|
setEnvVars(
|
|
Object.entries(loadedSettings.env).map(([key, value], index) => ({
|
|
id: `env-${index}`,
|
|
key,
|
|
value: value as string,
|
|
}))
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load settings:", err);
|
|
setError(t('settings.messages.loadFailed'));
|
|
setSettings({});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Saves the current settings
|
|
*/
|
|
const saveSettings = async () => {
|
|
try {
|
|
setSaving(true);
|
|
setError(null);
|
|
setToast(null);
|
|
|
|
// Build the settings object
|
|
const updatedSettings: ClaudeSettings = {
|
|
...settings,
|
|
permissions: {
|
|
allow: allowRules.map(rule => rule.value).filter(v => v && String(v).trim()),
|
|
deny: denyRules.map(rule => rule.value).filter(v => v && String(v).trim()),
|
|
},
|
|
env: envVars.reduce((acc, { key, value }) => {
|
|
if (key && String(key).trim() && value && String(value).trim()) {
|
|
acc[key] = String(value);
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, string>),
|
|
};
|
|
|
|
await api.saveClaudeSettings(updatedSettings);
|
|
setSettings(updatedSettings);
|
|
|
|
// Save Claude binary path if changed
|
|
if (binaryPathChanged && selectedInstallation) {
|
|
await api.setClaudeBinaryPath(selectedInstallation.path);
|
|
setCurrentBinaryPath(selectedInstallation.path);
|
|
setBinaryPathChanged(false);
|
|
}
|
|
|
|
// Save user hooks if changed
|
|
if (userHooksChanged && getUserHooks.current) {
|
|
const hooks = getUserHooks.current();
|
|
await api.updateHooksConfig('user', hooks);
|
|
setUserHooksChanged(false);
|
|
}
|
|
|
|
// Save proxy settings if changed
|
|
if (proxySettingsChanged && saveProxySettings.current) {
|
|
await saveProxySettings.current();
|
|
setProxySettingsChanged(false);
|
|
}
|
|
|
|
setToast({ message: t('settings.saveButton.settingsSavedSuccess'), type: "success" });
|
|
} catch (err) {
|
|
console.error("Failed to save settings:", err);
|
|
setError(t('settings.messages.saveFailed'));
|
|
setToast({ message: t('settings.saveButton.settingsSaveFailed'), type: "error" });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates a simple setting value
|
|
*/
|
|
const updateSetting = (key: string, value: any) => {
|
|
setSettings(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
/**
|
|
* Adds a new permission rule
|
|
*/
|
|
const addPermissionRule = (type: "allow" | "deny") => {
|
|
const newRule: PermissionRule = {
|
|
id: `${type}-${Date.now()}`,
|
|
value: "",
|
|
};
|
|
|
|
if (type === "allow") {
|
|
setAllowRules(prev => [...prev, newRule]);
|
|
} else {
|
|
setDenyRules(prev => [...prev, newRule]);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates a permission rule
|
|
*/
|
|
const updatePermissionRule = (type: "allow" | "deny", id: string, value: string) => {
|
|
if (type === "allow") {
|
|
setAllowRules(prev => prev.map(rule =>
|
|
rule.id === id ? { ...rule, value } : rule
|
|
));
|
|
} else {
|
|
setDenyRules(prev => prev.map(rule =>
|
|
rule.id === id ? { ...rule, value } : rule
|
|
));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Removes a permission rule
|
|
*/
|
|
const removePermissionRule = (type: "allow" | "deny", id: string) => {
|
|
if (type === "allow") {
|
|
setAllowRules(prev => prev.filter(rule => rule.id !== id));
|
|
} else {
|
|
setDenyRules(prev => prev.filter(rule => rule.id !== id));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Adds a new environment variable
|
|
*/
|
|
const addEnvVar = () => {
|
|
const newVar: EnvironmentVariable = {
|
|
id: `env-${Date.now()}`,
|
|
key: "",
|
|
value: "",
|
|
};
|
|
setEnvVars(prev => [...prev, newVar]);
|
|
};
|
|
|
|
/**
|
|
* Updates an environment variable
|
|
*/
|
|
const updateEnvVar = (id: string, field: "key" | "value", value: string) => {
|
|
setEnvVars(prev => prev.map(envVar =>
|
|
envVar.id === id ? { ...envVar, [field]: value } : envVar
|
|
));
|
|
};
|
|
|
|
/**
|
|
* Removes an environment variable
|
|
*/
|
|
const removeEnvVar = (id: string) => {
|
|
setEnvVars(prev => prev.filter(envVar => envVar.id !== id));
|
|
};
|
|
|
|
/**
|
|
* Handle Claude installation selection
|
|
*/
|
|
const handleClaudeInstallationSelect = (installation: ClaudeInstallation) => {
|
|
setSelectedInstallation(installation);
|
|
setBinaryPathChanged(installation.path !== currentBinaryPath);
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex flex-col h-full bg-background text-foreground", className)}>
|
|
<div className="max-w-4xl mx-auto w-full flex flex-col h-full">
|
|
{/* Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="flex items-center justify-between p-4 border-b border-border"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onBack}
|
|
className="h-8 w-8"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{t('settings.title')}</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.configurePreferences')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={saveSettings}
|
|
disabled={saving || loading}
|
|
size="sm"
|
|
className="gap-2 bg-primary hover:bg-primary/90"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('settings.saveButton.saving')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="h-4 w-4" />
|
|
{t('settings.saveButton.saveSettings')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</motion.div>
|
|
|
|
{/* Error message */}
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
className="mx-4 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/50 flex items-center gap-2 text-sm text-destructive"
|
|
>
|
|
<AlertCircle className="h-4 w-4" />
|
|
{error}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<TabsList className="grid grid-cols-9 w-full">
|
|
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
|
<TabsTrigger value="permissions">{t('settings.permissionsTab')}</TabsTrigger>
|
|
<TabsTrigger value="environment">{t('settings.environmentTab')}</TabsTrigger>
|
|
<TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
|
|
<TabsTrigger value="hooks">{t('settings.hooksTab')}</TabsTrigger>
|
|
<TabsTrigger value="commands">{t('settings.commands')}</TabsTrigger>
|
|
<TabsTrigger value="storage">{t('settings.storage')}</TabsTrigger>
|
|
<TabsTrigger value="proxy">{t('settings.proxy')}</TabsTrigger>
|
|
<TabsTrigger value="analytics">{t('settings.analyticsTab')}</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* General Settings */}
|
|
<TabsContent value="general" className="space-y-6">
|
|
<Card className="p-6 space-y-6">
|
|
<div>
|
|
<h3 className="text-base font-semibold mb-4">{t('settings.generalSettings')}</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* Theme Selector */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="theme">{t('settings.theme')}</Label>
|
|
<Select
|
|
value={theme}
|
|
onValueChange={(value) => setTheme(value as any)}
|
|
>
|
|
<SelectTrigger id="theme" className="w-full">
|
|
<SelectValue placeholder={t('settings.themeSelector.selectATheme')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="dark">{t('settings.themeSelector.dark')}</SelectItem>
|
|
<SelectItem value="gray">{t('settings.themeSelector.gray')}</SelectItem>
|
|
<SelectItem value="light">{t('settings.themeSelector.light')}</SelectItem>
|
|
<SelectItem value="custom">{t('settings.themeSelector.custom')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.themeSelector.choosePreferredTheme')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Custom Color Editor */}
|
|
{theme === 'custom' && (
|
|
<div className="space-y-4 p-4 border rounded-lg bg-muted/20">
|
|
<h4 className="text-sm font-medium">{t('settings.customTheme.title')}</h4>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Background Color */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color-background" className="text-xs">{t('settings.customTheme.background')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="color-background"
|
|
type="text"
|
|
value={customColors.background}
|
|
onChange={(e) => setCustomColors({ background: e.target.value })}
|
|
placeholder="oklch(0.12 0.01 240)"
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div
|
|
className="w-10 h-10 rounded border"
|
|
style={{ backgroundColor: customColors.background }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Foreground Color */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color-foreground" className="text-xs">{t('settings.customTheme.foreground')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="color-foreground"
|
|
type="text"
|
|
value={customColors.foreground}
|
|
onChange={(e) => setCustomColors({ foreground: e.target.value })}
|
|
placeholder="oklch(0.98 0.01 240)"
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div
|
|
className="w-10 h-10 rounded border"
|
|
style={{ backgroundColor: customColors.foreground }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Primary Color */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color-primary" className="text-xs">{t('settings.customTheme.primary')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="color-primary"
|
|
type="text"
|
|
value={customColors.primary}
|
|
onChange={(e) => setCustomColors({ primary: e.target.value })}
|
|
placeholder="oklch(0.98 0.01 240)"
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div
|
|
className="w-10 h-10 rounded border"
|
|
style={{ backgroundColor: customColors.primary }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Color */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color-card" className="text-xs">{t('settings.customTheme.card')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="color-card"
|
|
type="text"
|
|
value={customColors.card}
|
|
onChange={(e) => setCustomColors({ card: e.target.value })}
|
|
placeholder="oklch(0.14 0.01 240)"
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div
|
|
className="w-10 h-10 rounded border"
|
|
style={{ backgroundColor: customColors.card }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Accent Color */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color-accent" className="text-xs">{t('settings.customTheme.accent')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="color-accent"
|
|
type="text"
|
|
value={customColors.accent}
|
|
onChange={(e) => setCustomColors({ accent: e.target.value })}
|
|
placeholder="oklch(0.16 0.01 240)"
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div
|
|
className="w-10 h-10 rounded border"
|
|
style={{ backgroundColor: customColors.accent }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Destructive Color */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color-destructive" className="text-xs">{t('settings.customTheme.destructive')}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="color-destructive"
|
|
type="text"
|
|
value={customColors.destructive}
|
|
onChange={(e) => setCustomColors({ destructive: e.target.value })}
|
|
placeholder="oklch(0.6 0.2 25)"
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div
|
|
className="w-10 h-10 rounded border"
|
|
style={{ backgroundColor: customColors.destructive }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.customTheme.colorValuesDesc')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Include Co-authored By */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5 flex-1">
|
|
<Label htmlFor="coauthored">{t('settings.generalOptions.includeCoAuthor')}</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.generalOptions.includeCoAuthorDesc')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="coauthored"
|
|
checked={settings?.includeCoAuthoredBy !== false}
|
|
onCheckedChange={(checked) => updateSetting("includeCoAuthoredBy", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Verbose Output */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5 flex-1">
|
|
<Label htmlFor="verbose">{t('settings.generalOptions.verboseOutput')}</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.generalOptions.verboseOutputDesc')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="verbose"
|
|
checked={settings?.verbose === true}
|
|
onCheckedChange={(checked) => updateSetting("verbose", checked)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Cleanup Period */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cleanup">{t('settings.generalOptions.chatRetention')}</Label>
|
|
<Input
|
|
id="cleanup"
|
|
type="number"
|
|
min="1"
|
|
placeholder="30"
|
|
value={settings?.cleanupPeriodDays || ""}
|
|
onChange={(e) => {
|
|
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
|
updateSetting("cleanupPeriodDays", value);
|
|
}}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.generalOptions.chatRetentionDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Claude Binary Path Selector */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="text-sm font-medium mb-2 block">{t('settings.generalOptions.claudeCodeInstallation')}</Label>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
{t('settings.generalOptions.claudeCodeInstallationDesc')}
|
|
</p>
|
|
</div>
|
|
<ClaudeVersionSelector
|
|
selectedPath={currentBinaryPath}
|
|
onSelect={handleClaudeInstallationSelect}
|
|
/>
|
|
{binaryPathChanged && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
|
{t('settings.generalOptions.binaryPathChanged')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Permissions Settings */}
|
|
<TabsContent value="permissions" className="space-y-6">
|
|
<Card className="p-6">
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-base font-semibold mb-2">{t('settings.permissions.permissionRules')}</h3>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
{t('settings.permissions.permissionRulesDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Allow Rules */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium text-green-500">{t('settings.permissions.allowRules')}</Label>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addPermissionRule("allow")}
|
|
className="gap-2 hover:border-green-500/50 hover:text-green-500"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t('settings.permissions.addRule')}
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{allowRules.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-2">
|
|
{t('settings.permissions.noAllowRules')}
|
|
</p>
|
|
) : (
|
|
allowRules.map((rule) => (
|
|
<motion.div
|
|
key={rule.id}
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Input
|
|
placeholder={t('settings.placeholders.allowRuleExample')}
|
|
value={rule.value}
|
|
onChange={(e) => updatePermissionRule("allow", rule.id, e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removePermissionRule("allow", rule.id)}
|
|
className="h-8 w-8"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deny Rules */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium text-red-500">{t('settings.permissions.denyRules')}</Label>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addPermissionRule("deny")}
|
|
className="gap-2 hover:border-red-500/50 hover:text-red-500"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t('settings.permissions.addRule')}
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{denyRules.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-2">
|
|
{t('settings.permissions.noDenyRules')}
|
|
</p>
|
|
) : (
|
|
denyRules.map((rule) => (
|
|
<motion.div
|
|
key={rule.id}
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Input
|
|
placeholder={t('settings.placeholders.denyRuleExample')}
|
|
value={rule.value}
|
|
onChange={(e) => updatePermissionRule("deny", rule.id, e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removePermissionRule("deny", rule.id)}
|
|
className="h-8 w-8"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-2 space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
<strong>{t('settings.permissions.examples')}</strong>
|
|
</p>
|
|
<ul className="text-xs text-muted-foreground space-y-1 ml-4">
|
|
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Bash</code> - {t('settings.permissions.exampleBash')}</li>
|
|
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Bash(npm run build)</code> - {t('settings.permissions.exampleExactCommand')}</li>
|
|
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Bash(npm run test:*)</code> - {t('settings.permissions.examplePrefix')}</li>
|
|
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Read(~/.zshrc)</code> - {t('settings.permissions.exampleReadFile')}</li>
|
|
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Edit(docs/**)</code> - {t('settings.permissions.exampleEditDir')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Environment Variables */}
|
|
<TabsContent value="environment" className="space-y-6">
|
|
<Card className="p-6">
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-base font-semibold">{t('settings.environment.environmentVariables')}</h3>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{t('settings.environment.environmentVariablesDesc')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addEnvVar}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t('settings.environment.addVariable')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{envVars.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-2">
|
|
{t('settings.environment.noEnvironmentVariables')}
|
|
</p>
|
|
) : (
|
|
envVars.map((envVar) => (
|
|
<motion.div
|
|
key={envVar.id}
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Input
|
|
placeholder={t('settings.placeholders.envVarKey')}
|
|
value={envVar.key}
|
|
onChange={(e) => updateEnvVar(envVar.id, "key", e.target.value)}
|
|
className="flex-1 font-mono text-sm"
|
|
/>
|
|
<span className="text-muted-foreground">=</span>
|
|
<Input
|
|
placeholder={t('settings.placeholders.envVarValue')}
|
|
value={envVar.value}
|
|
onChange={(e) => updateEnvVar(envVar.id, "value", e.target.value)}
|
|
className="flex-1 font-mono text-sm"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeEnvVar(envVar.id)}
|
|
className="h-8 w-8 hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-2 space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
<strong>{t('settings.environment.commonVariables')}</strong>
|
|
</p>
|
|
<ul className="text-xs text-muted-foreground space-y-1 ml-4">
|
|
<li>• <code className="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">CLAUDE_CODE_ENABLE_TELEMETRY</code> - {t('settings.environment.telemetryDesc')}</li>
|
|
<li>• <code className="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">ANTHROPIC_MODEL</code> - {t('settings.environment.modelDesc')}</li>
|
|
<li>• <code className="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">DISABLE_COST_WARNINGS</code> - {t('settings.environment.costWarningsDesc')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</TabsContent>
|
|
{/* Advanced Settings */}
|
|
<TabsContent value="advanced" className="space-y-6">
|
|
<Card className="p-6">
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-base font-semibold mb-4">{t('settings.advanced.advancedSettings')}</h3>
|
|
<p className="text-sm text-muted-foreground mb-6">
|
|
{t('settings.advanced.advancedSettingsDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* API Key Helper */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="apiKeyHelper">{t('settings.advanced.apiKeyHelper')}</Label>
|
|
<Input
|
|
id="apiKeyHelper"
|
|
placeholder={t('settings.placeholders.apiKeyHelperPath')}
|
|
value={settings?.apiKeyHelper || ""}
|
|
onChange={(e) => updateSetting("apiKeyHelper", e.target.value || undefined)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.advanced.apiKeyHelperDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Raw JSON Editor */}
|
|
<div className="space-y-2">
|
|
<Label>{t('settings.advanced.rawSettings')}</Label>
|
|
<div className="p-3 rounded-md bg-muted font-mono text-xs overflow-x-auto whitespace-pre-wrap">
|
|
<pre>{JSON.stringify(settings, null, 2)}</pre>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.advanced.rawSettingsDesc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Hooks Settings */}
|
|
<TabsContent value="hooks" className="space-y-6">
|
|
<Card className="p-6">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-base font-semibold mb-2">{t('settings.hooks.userHooks')}</h3>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
{t('settings.hooks.userHooksDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
<HooksEditor
|
|
key={activeTab}
|
|
scope="user"
|
|
className="border-0"
|
|
hideActions={true}
|
|
onChange={(hasChanges, getHooks) => {
|
|
setUserHooksChanged(hasChanges);
|
|
getUserHooks.current = getHooks;
|
|
}}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Commands Tab */}
|
|
<TabsContent value="commands">
|
|
<Card className="p-6">
|
|
<SlashCommandsManager className="p-0" />
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Storage Tab */}
|
|
<TabsContent value="storage">
|
|
<StorageTab />
|
|
</TabsContent>
|
|
|
|
{/* Proxy Settings */}
|
|
<TabsContent value="proxy">
|
|
<Card className="p-6">
|
|
<ProxySettings
|
|
setToast={setToast}
|
|
onChange={(hasChanges, _getSettings, save) => {
|
|
setProxySettingsChanged(hasChanges);
|
|
saveProxySettings.current = save;
|
|
}}
|
|
/>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Analytics Settings */}
|
|
<TabsContent value="analytics" className="space-y-6">
|
|
<Card className="p-6 space-y-6">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<BarChart3 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
|
<h3 className="text-base font-semibold">{t('settings.analytics.analyticsSettings')}</h3>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* Analytics Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="analytics-enabled" className="text-base">{t('settings.analytics.enableAnalytics')}</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.analytics.enableAnalyticsDesc')}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="analytics-enabled"
|
|
checked={analyticsEnabled}
|
|
onCheckedChange={async (checked) => {
|
|
if (checked && !analyticsConsented) {
|
|
setShowAnalyticsConsent(true);
|
|
} else if (checked) {
|
|
await analytics.enable();
|
|
setAnalyticsEnabled(true);
|
|
trackEvent.settingsChanged('analytics_enabled', true);
|
|
setToast({ message: t('settings.analytics.analyticsEnabled'), type: "success" });
|
|
} else {
|
|
await analytics.disable();
|
|
setAnalyticsEnabled(false);
|
|
trackEvent.settingsChanged('analytics_enabled', false);
|
|
setToast({ message: t('settings.analytics.analyticsDisabled'), type: "success" });
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Privacy Info */}
|
|
<div className="rounded-lg border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-950/20 p-4">
|
|
<div className="flex gap-3">
|
|
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
|
<div className="space-y-2">
|
|
<p className="font-medium text-blue-900 dark:text-blue-100">{t('settings.analytics.privacyProtected')}</p>
|
|
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
|
<li>• {t('settings.analytics.noPersonalInfo')}</li>
|
|
<li>• {t('settings.analytics.noFileContents')}</li>
|
|
<li>• {t('settings.analytics.anonymousData')}</li>
|
|
<li>• {t('settings.analytics.canDisable')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data Collection Info */}
|
|
{analyticsEnabled && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2">{t('settings.analytics.whatWeCollect')}</h4>
|
|
<ul className="text-sm text-muted-foreground space-y-1">
|
|
<li>• {t('settings.analytics.featureUsage')}</li>
|
|
<li>• {t('settings.analytics.performanceMetrics')}</li>
|
|
<li>• {t('settings.analytics.errorReports')}</li>
|
|
<li>• {t('settings.analytics.sessionFrequency')}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Delete Data Button */}
|
|
<div className="pt-4 border-t">
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={async () => {
|
|
await analytics.deleteAllData();
|
|
setAnalyticsEnabled(false);
|
|
setAnalyticsConsented(false);
|
|
setToast({ message: t('settings.analytics.allDataDeleted'), type: "success" });
|
|
}}
|
|
>
|
|
<Trash className="mr-2 h-4 w-4" />
|
|
{t('settings.analytics.deleteAllData')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Toast Notification */}
|
|
<ToastContainer>
|
|
{toast && (
|
|
<Toast
|
|
message={toast.message}
|
|
type={toast.type}
|
|
onDismiss={() => setToast(null)}
|
|
/>
|
|
)}
|
|
</ToastContainer>
|
|
|
|
{/* Analytics Consent Dialog */}
|
|
<AnalyticsConsent
|
|
open={showAnalyticsConsent}
|
|
onOpenChange={setShowAnalyticsConsent}
|
|
onComplete={async () => {
|
|
await loadAnalyticsSettings();
|
|
setShowAnalyticsConsent(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|