feat: redesign settings page with sidebar navigation
Extract settings into General / Providers / Claude CLI sections with a left sidebar layout. Keep Skills and MCP on the Extensions page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5cff792e3
commit
d5b1d6eb9d
5 files changed
+613
-544
No files matched your search
+4
-543
@@ -1,52 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Suspense } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
FloppyDiskIcon,
|
||||
ReloadIcon,
|
||||
CodeIcon,
|
||||
SlidersHorizontalIcon,
|
||||
Loading02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ProviderManager } from "@/components/settings/ProviderManager";
|
||||
import { useUpdate } from "@/hooks/useUpdate";
|
||||
|
||||
interface SettingsData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Structured known fields from ~/.claude/settings.json
|
||||
const KNOWN_FIELDS = [
|
||||
{
|
||||
key: "permissions",
|
||||
label: "Permissions",
|
||||
description: "Configure permission settings for Claude CLI",
|
||||
type: "object" as const,
|
||||
},
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment Variables",
|
||||
description: "Environment variables passed to Claude",
|
||||
type: "object" as const,
|
||||
},
|
||||
] as const;
|
||||
import { Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { SettingsLayout } from "@/components/settings/SettingsLayout";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
@@ -57,503 +14,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPageInner />
|
||||
<SettingsLayout />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// --- About & Updates card ---
|
||||
function UpdateCard() {
|
||||
const { updateInfo, checking, checkForUpdates } = useUpdate();
|
||||
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">CodePilot</h2>
|
||||
<p className="text-xs text-muted-foreground">Version {currentVersion}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checking}
|
||||
className="gap-2"
|
||||
>
|
||||
{checking ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{checking ? "Checking..." : "Check for Updates"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{updateInfo && !checking && (
|
||||
<div className="mt-3">
|
||||
{updateInfo.updateAvailable ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-sm">
|
||||
Update available: <span className="font-medium">v{updateInfo.latestVersion}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-sm"
|
||||
onClick={() => window.open(updateInfo.releaseUrl, "_blank")}
|
||||
>
|
||||
View Release
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">You're on the latest version.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Claude CLI Settings Section (manages ~/.claude/settings.json) ---
|
||||
function SettingsPageInner() {
|
||||
const [settings, setSettings] = useState<SettingsData>({});
|
||||
const [originalSettings, setOriginalSettings] = useState<SettingsData>({});
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingSaveAction, setPendingSaveAction] = useState<
|
||||
"form" | "json" | null
|
||||
>(null);
|
||||
|
||||
// Skip-permissions toggle state
|
||||
const [skipPermissions, setSkipPermissions] = useState(false);
|
||||
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
|
||||
const [skipPermSaving, setSkipPermSaving] = useState(false);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const s = data.settings || {};
|
||||
setSettings(s);
|
||||
setOriginalSettings(s);
|
||||
setJsonText(JSON.stringify(s, null, 2));
|
||||
}
|
||||
} catch {
|
||||
setSettings({});
|
||||
setOriginalSettings({});
|
||||
setJsonText("{}");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch app-level settings (dangerously_skip_permissions)
|
||||
const fetchAppSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/app");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const appSettings = data.settings || {};
|
||||
setSkipPermissions(appSettings.dangerously_skip_permissions === "true");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchAppSettings();
|
||||
}, [fetchSettings, fetchAppSettings]);
|
||||
|
||||
const hasChanges =
|
||||
JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
|
||||
const handleSave = async (source: "form" | "json") => {
|
||||
let dataToSave: SettingsData;
|
||||
|
||||
if (source === "json") {
|
||||
try {
|
||||
dataToSave = JSON.parse(jsonText);
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Invalid JSON format");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
dataToSave = settings;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: dataToSave }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSettings(dataToSave);
|
||||
setOriginalSettings(dataToSave);
|
||||
setJsonText(JSON.stringify(dataToSave, null, 2));
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
}
|
||||
} catch {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowConfirmDialog(false);
|
||||
setPendingSaveAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSettings(originalSettings);
|
||||
setJsonText(JSON.stringify(originalSettings, null, 2));
|
||||
setJsonError("");
|
||||
};
|
||||
|
||||
const handleFormatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setJsonText(JSON.stringify(parsed, null, 2));
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Cannot format: invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSave = (source: "form" | "json") => {
|
||||
setPendingSaveAction(source);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: unknown) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Handle skip-permissions toggle
|
||||
const handleSkipPermToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
// Show warning dialog before enabling
|
||||
setShowSkipPermWarning(true);
|
||||
} else {
|
||||
// Disable immediately without warning
|
||||
saveSkipPermissions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSkipPermissions = async (enabled: boolean) => {
|
||||
setSkipPermSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/app", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
settings: { dangerously_skip_permissions: enabled ? "true" : "" },
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSkipPermissions(enabled);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSkipPermSaving(false);
|
||||
setShowSkipPermWarning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border/50 px-6 pt-4 pb-4">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage CodePilot and Claude CLI settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<UpdateCard />
|
||||
<ProviderManager />
|
||||
|
||||
{/* Dangerous Settings */}
|
||||
<div className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Auto-approve All Actions</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip all permission checks and auto-approve every tool action.
|
||||
This is dangerous and should only be used for trusted tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={handleSkipPermToggle}
|
||||
disabled={skipPermSaving}
|
||||
/>
|
||||
</div>
|
||||
{skipPermissions && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
|
||||
All tool actions will be auto-approved without confirmation. Use with caution.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
Loading settings...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="form">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="form" className="gap-2">
|
||||
<HugeiconsIcon icon={SlidersHorizontalIcon} className="h-4 w-4" />
|
||||
Visual Editor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-2">
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
|
||||
JSON Editor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="form">
|
||||
<div className="space-y-6">
|
||||
{KNOWN_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
</Label>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
<Textarea
|
||||
value={
|
||||
typeof settings[field.key] === "object"
|
||||
? JSON.stringify(settings[field.key], null, 2)
|
||||
: String(settings[field.key] ?? "")
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
updateField(field.key, parsed);
|
||||
} catch {
|
||||
updateField(field.key, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(settings)
|
||||
.filter(
|
||||
([key]) => !KNOWN_FIELDS.some((f) => f.key === key)
|
||||
)
|
||||
.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<Label className="text-sm font-medium">{key}</Label>
|
||||
{typeof value === "boolean" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(key, checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
) : typeof value === "string" ? (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateField(key, e.target.value)
|
||||
}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
updateField(key, JSON.parse(e.target.value));
|
||||
} catch {
|
||||
updateField(key, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="mt-2 font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => confirmSave("form")}
|
||||
disabled={!hasChanges || saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges}
|
||||
className="gap-2"
|
||||
>
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Settings saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json">
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
setJsonError("");
|
||||
}}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-sm text-destructive">{jsonError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => confirmSave("json")}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save JSON"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormatJson}
|
||||
className="gap-2"
|
||||
>
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
>
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Settings saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Save</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will overwrite your current ~/.claude/settings.json file. Are
|
||||
you sure you want to continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => pendingSaveAction && handleSave(pendingSaveAction)}
|
||||
>
|
||||
Save
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Skip-permissions warning dialog */}
|
||||
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Auto-approve All Actions?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
This will bypass all permission checks. Claude will be able to
|
||||
execute any tool action without asking for your confirmation,
|
||||
including:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Running arbitrary shell commands</li>
|
||||
<li>Reading, writing, and deleting files</li>
|
||||
<li>Making network requests</li>
|
||||
</ul>
|
||||
<p className="font-medium text-orange-600 dark:text-orange-400">
|
||||
Only enable this if you fully trust the task at hand. This
|
||||
setting applies to all new chat sessions.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => saveSkipPermissions(true)}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
Enable Auto-approve
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import {
|
||||
Message02Icon,
|
||||
PlusSignIcon,
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
FloppyDiskIcon,
|
||||
ReloadIcon,
|
||||
CodeIcon,
|
||||
SlidersHorizontalIcon,
|
||||
Loading02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
||||
interface SettingsData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const KNOWN_FIELDS = [
|
||||
{
|
||||
key: "permissions",
|
||||
label: "Permissions",
|
||||
description: "Configure permission settings for Claude CLI",
|
||||
type: "object" as const,
|
||||
},
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment Variables",
|
||||
description: "Environment variables passed to Claude",
|
||||
type: "object" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function CliSettingsSection() {
|
||||
const [settings, setSettings] = useState<SettingsData>({});
|
||||
const [originalSettings, setOriginalSettings] = useState<SettingsData>({});
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingSaveAction, setPendingSaveAction] = useState<"form" | "json" | null>(null);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const s = data.settings || {};
|
||||
setSettings(s);
|
||||
setOriginalSettings(s);
|
||||
setJsonText(JSON.stringify(s, null, 2));
|
||||
}
|
||||
} catch {
|
||||
setSettings({});
|
||||
setOriginalSettings({});
|
||||
setJsonText("{}");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const hasChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
|
||||
const handleSave = async (source: "form" | "json") => {
|
||||
let dataToSave: SettingsData;
|
||||
|
||||
if (source === "json") {
|
||||
try {
|
||||
dataToSave = JSON.parse(jsonText);
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Invalid JSON format");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
dataToSave = settings;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: dataToSave }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSettings(dataToSave);
|
||||
setOriginalSettings(dataToSave);
|
||||
setJsonText(JSON.stringify(dataToSave, null, 2));
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
}
|
||||
} catch {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowConfirmDialog(false);
|
||||
setPendingSaveAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSettings(originalSettings);
|
||||
setJsonText(JSON.stringify(originalSettings, null, 2));
|
||||
setJsonError("");
|
||||
};
|
||||
|
||||
const handleFormatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setJsonText(JSON.stringify(parsed, null, 2));
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Cannot format: invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSave = (source: "form" | "json") => {
|
||||
setPendingSaveAction(source);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: unknown) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<Tabs defaultValue="form">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="form" className="gap-2">
|
||||
<HugeiconsIcon icon={SlidersHorizontalIcon} className="h-4 w-4" />
|
||||
Visual Editor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-2">
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
|
||||
JSON Editor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="form">
|
||||
<div className="space-y-6">
|
||||
{KNOWN_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<Label className="text-sm font-medium">{field.label}</Label>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{field.description}</p>
|
||||
<Textarea
|
||||
value={
|
||||
typeof settings[field.key] === "object"
|
||||
? JSON.stringify(settings[field.key], null, 2)
|
||||
: String(settings[field.key] ?? "")
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
updateField(field.key, parsed);
|
||||
} catch {
|
||||
updateField(field.key, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(settings)
|
||||
.filter(([key]) => !KNOWN_FIELDS.some((f) => f.key === key))
|
||||
.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<Label className="text-sm font-medium">{key}</Label>
|
||||
{typeof value === "boolean" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => updateField(key, checked)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
) : typeof value === "string" ? (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateField(key, e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
updateField(key, JSON.parse(e.target.value));
|
||||
} catch {
|
||||
updateField(key, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="mt-2 font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => confirmSave("form")} disabled={!hasChanges || saving} className="gap-2">
|
||||
{saving ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset} disabled={!hasChanges} className="gap-2">
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Settings saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json">
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
setJsonError("");
|
||||
}}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{jsonError && <p className="text-sm text-destructive">{jsonError}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => confirmSave("json")} disabled={saving} className="gap-2">
|
||||
{saving ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save JSON"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleFormatJson} className="gap-2">
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
|
||||
Format
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset} className="gap-2">
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Settings saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Save</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will overwrite your current ~/.claude/settings.json file. Are you sure you want to continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => pendingSaveAction && handleSave(pendingSaveAction)}>
|
||||
Save
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ReloadIcon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { useUpdate } from "@/hooks/useUpdate";
|
||||
|
||||
function UpdateCard() {
|
||||
const { updateInfo, checking, checkForUpdates } = useUpdate();
|
||||
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">CodePilot</h2>
|
||||
<p className="text-xs text-muted-foreground">Version {currentVersion}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkForUpdates}
|
||||
disabled={checking}
|
||||
className="gap-2"
|
||||
>
|
||||
{checking ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{checking ? "Checking..." : "Check for Updates"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{updateInfo && !checking && (
|
||||
<div className="mt-3">
|
||||
{updateInfo.updateAvailable ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-sm">
|
||||
Update available: <span className="font-medium">v{updateInfo.latestVersion}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-sm"
|
||||
onClick={() => window.open(updateInfo.releaseUrl, "_blank")}
|
||||
>
|
||||
View Release
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">You're on the latest version.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralSection() {
|
||||
const [skipPermissions, setSkipPermissions] = useState(false);
|
||||
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
|
||||
const [skipPermSaving, setSkipPermSaving] = useState(false);
|
||||
|
||||
const fetchAppSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/app");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const appSettings = data.settings || {};
|
||||
setSkipPermissions(appSettings.dangerously_skip_permissions === "true");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAppSettings();
|
||||
}, [fetchAppSettings]);
|
||||
|
||||
const handleSkipPermToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setShowSkipPermWarning(true);
|
||||
} else {
|
||||
saveSkipPermissions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSkipPermissions = async (enabled: boolean) => {
|
||||
setSkipPermSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/app", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
settings: { dangerously_skip_permissions: enabled ? "true" : "" },
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSkipPermissions(enabled);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSkipPermSaving(false);
|
||||
setShowSkipPermWarning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<UpdateCard />
|
||||
|
||||
{/* Auto-approve toggle */}
|
||||
<div className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${skipPermissions ? "border-orange-500/50 bg-orange-500/5" : "border-border/50"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Auto-approve All Actions</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip all permission checks and auto-approve every tool action.
|
||||
This is dangerous and should only be used for trusted tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={handleSkipPermToggle}
|
||||
disabled={skipPermSaving}
|
||||
/>
|
||||
</div>
|
||||
{skipPermissions && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-orange-500/10 px-3 py-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-orange-500" />
|
||||
All tool actions will be auto-approved without confirmation. Use with caution.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip-permissions warning dialog */}
|
||||
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Auto-approve All Actions?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
This will bypass all permission checks. Claude will be able to
|
||||
execute any tool action without asking for your confirmation,
|
||||
including:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Running arbitrary shell commands</li>
|
||||
<li>Reading, writing, and deleting files</li>
|
||||
<li>Making network requests</li>
|
||||
</ul>
|
||||
<p className="font-medium text-orange-600 dark:text-orange-400">
|
||||
Only enable this if you fully trust the task at hand. This
|
||||
setting applies to all new chat sessions.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => saveSkipPermissions(true)}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
Enable Auto-approve
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import {
|
||||
Settings02Icon,
|
||||
CodeIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { Plug01Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GeneralSection } from "./GeneralSection";
|
||||
import { ProviderManager } from "./ProviderManager";
|
||||
import { CliSettingsSection } from "./CliSettingsSection";
|
||||
|
||||
type Section = "general" | "providers" | "cli";
|
||||
|
||||
interface SidebarItem {
|
||||
id: Section;
|
||||
label: string;
|
||||
icon: IconSvgElement;
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ id: "general", label: "General", icon: Settings02Icon },
|
||||
{ id: "providers", label: "Providers", icon: Plug01Icon },
|
||||
{ id: "cli", label: "Claude CLI", icon: CodeIcon },
|
||||
];
|
||||
|
||||
function getInitialSection(): Section {
|
||||
if (typeof window !== "undefined") {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
if (sidebarItems.some((item) => item.id === hash)) {
|
||||
return hash as Section;
|
||||
}
|
||||
}
|
||||
return "general";
|
||||
}
|
||||
|
||||
export function SettingsLayout() {
|
||||
const [activeSection, setActiveSection] = useState<Section>(getInitialSection);
|
||||
|
||||
// Sync hash on mount and on popstate
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
if (sidebarItems.some((item) => item.id === hash)) {
|
||||
setActiveSection(hash as Section);
|
||||
}
|
||||
};
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
const handleSectionChange = (section: Section) => {
|
||||
setActiveSection(section);
|
||||
window.history.replaceState(null, "", `/settings#${section}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border/50 px-6 pt-4 pb-4">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage CodePilot and Claude CLI settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Sidebar */}
|
||||
<nav className="flex w-52 shrink-0 flex-col gap-1 border-r border-border/50 p-3">
|
||||
{sidebarItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSectionChange(item.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left",
|
||||
activeSection === item.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeSection === "general" && <GeneralSection />}
|
||||
{activeSection === "providers" && <ProviderManager />}
|
||||
{activeSection === "cli" && <CliSettingsSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user