mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +00:00
feat(api-profile-ux): implement tabbed profile editor and fix disclaimer visibility
This commit is contained in:
1 parent
83570050ef
commit
8c9d669cce
5 files changed
+348
-315
No files matched your search
+7
-5
@@ -43,16 +43,18 @@ function Layout() {
|
|||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<header className="flex h-12 items-center justify-end px-4 border-b">
|
<header className="flex h-12 items-center justify-end px-4 border-b shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<ConnectionIndicator />
|
<ConnectionIndicator />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<div className="flex-1 overflow-auto pb-12">
|
||||||
<Outlet />
|
<Suspense fallback={<PageLoader />}>
|
||||||
</Suspense>
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
<LocalhostDisclaimer />
|
<LocalhostDisclaimer />
|
||||||
</main>
|
</main>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@@ -1,30 +1,13 @@
|
|||||||
import { Shield, X } from 'lucide-react';
|
import { Shield, X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSidebar } from '@/hooks/use-sidebar';
|
|
||||||
|
|
||||||
export function LocalhostDisclaimer() {
|
export function LocalhostDisclaimer() {
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const { state, isMobile } = useSidebar();
|
|
||||||
|
|
||||||
if (dismissed) return null;
|
if (dismissed) return null;
|
||||||
|
|
||||||
// Calculate the left margin based on sidebar state
|
|
||||||
// When expanded: sidebar width is 16rem
|
|
||||||
// When collapsed: sidebar width is 3rem
|
|
||||||
// On mobile: sidebar is overlay, no margin needed
|
|
||||||
const getLeftMargin = () => {
|
|
||||||
if (isMobile) return '0';
|
|
||||||
return state === 'expanded' ? '16rem' : '3rem';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed bottom-0 left-0 right-0 bg-yellow-50 dark:bg-yellow-900/20 border-t border-yellow-200 dark:border-yellow-800 px-4 py-2 z-50 md:pl-[var(--sidebar-width)] transition-[padding] duration-200">
|
||||||
className="fixed bottom-0 bg-yellow-50 dark:bg-yellow-900/20 border-t border-yellow-200 dark:border-yellow-800 px-4 py-2 transition-all duration-200 ease-linear z-50"
|
|
||||||
style={{
|
|
||||||
left: getLeftMargin(),
|
|
||||||
right: '0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
<div className="flex items-center justify-center gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-yellow-800 dark:text-yellow-200">
|
<div className="flex items-center gap-2 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<Shield className="w-4 h-4 flex-shrink-0" />
|
<Shield className="w-4 h-4 flex-shrink-0" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Profile Editor Component
|
* Profile Editor Component
|
||||||
* Inline editor for API profile settings with tabs for Environment/Raw JSON/Info
|
* Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||||
@@ -9,24 +9,13 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { MaskedInput } from '@/components/ui/masked-input';
|
import { MaskedInput } from '@/components/ui/masked-input';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import {
|
import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react';
|
||||||
Save,
|
|
||||||
Loader2,
|
|
||||||
Code2,
|
|
||||||
Settings,
|
|
||||||
Info,
|
|
||||||
Terminal,
|
|
||||||
Trash2,
|
|
||||||
RefreshCw,
|
|
||||||
Plus,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
|
||||||
// Lazy load CodeEditor to reduce initial bundle size
|
// Lazy load CodeEditor to reduce initial bundle size
|
||||||
const CodeEditor = lazy(() =>
|
const CodeEditor = lazy(() =>
|
||||||
@@ -53,7 +42,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||||
const [conflictDialog, setConflictDialog] = useState(false);
|
const [conflictDialog, setConflictDialog] = useState(false);
|
||||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('env');
|
|
||||||
const [newEnvKey, setNewEnvKey] = useState('');
|
const [newEnvKey, setNewEnvKey] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -80,7 +68,18 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Derive current settings by merging original data with local edits
|
// Derive current settings by merging original data with local edits
|
||||||
|
// Prioritize rawJsonEdits if available
|
||||||
const currentSettings = useMemo((): Settings | undefined => {
|
const currentSettings = useMemo((): Settings | undefined => {
|
||||||
|
if (rawJsonEdits !== null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawJsonEdits);
|
||||||
|
} catch {
|
||||||
|
// If invalid JSON, fall back to undefined or partial state
|
||||||
|
// The UI will likely show empty or potentially broken state if JSON is invalid,
|
||||||
|
// but the Raw Editor will show the error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!settings) return undefined;
|
if (!settings) return undefined;
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
@@ -89,7 +88,38 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
...localEdits,
|
...localEdits,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [settings, localEdits]);
|
}, [settings, localEdits, rawJsonEdits]);
|
||||||
|
|
||||||
|
// Sync Visual Editor changes to Raw JSON
|
||||||
|
const updateEnvValue = (key: string, value: string) => {
|
||||||
|
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
||||||
|
|
||||||
|
// Update local edits
|
||||||
|
setLocalEdits((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update rawJsonEdits to keep sync
|
||||||
|
const newSettings = { ...currentSettings, env: newEnv };
|
||||||
|
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewEnvVar = () => {
|
||||||
|
if (!newEnvKey.trim()) return;
|
||||||
|
const key = newEnvKey.trim();
|
||||||
|
const newEnv = { ...(currentSettings?.env || {}), [key]: '' };
|
||||||
|
|
||||||
|
setLocalEdits((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newSettings = { ...currentSettings, env: newEnv };
|
||||||
|
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||||
|
|
||||||
|
setNewEnvKey('');
|
||||||
|
};
|
||||||
|
|
||||||
// Check if raw JSON is valid
|
// Check if raw JSON is valid
|
||||||
const isRawJsonValid = useMemo(() => {
|
const isRawJsonValid = useMemo(() => {
|
||||||
@@ -103,24 +133,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
|
|
||||||
// Check if there are unsaved changes
|
// Check if there are unsaved changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (activeTab === 'raw') {
|
if (rawJsonEdits !== null) {
|
||||||
return rawJsonEdits !== null;
|
return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
||||||
}
|
}
|
||||||
return Object.keys(localEdits).length > 0;
|
return Object.keys(localEdits).length > 0;
|
||||||
}, [activeTab, rawJsonEdits, localEdits]);
|
}, [rawJsonEdits, localEdits, settings]);
|
||||||
|
|
||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
let settingsToSave: Settings;
|
let settingsToSave: Settings;
|
||||||
|
|
||||||
if (activeTab === 'raw') {
|
try {
|
||||||
try {
|
// Always save from rawJsonContent as it's the source of truth
|
||||||
settingsToSave = JSON.parse(rawJsonContent);
|
settingsToSave = JSON.parse(rawJsonContent);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid JSON');
|
// Fallback (should typically not happen if validation is correct)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
settingsToSave = {
|
settingsToSave = {
|
||||||
...data?.settings,
|
...data?.settings,
|
||||||
env: {
|
env: {
|
||||||
@@ -180,22 +208,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEnvValue = (key: string, value: string) => {
|
|
||||||
setLocalEdits((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addNewEnvVar = () => {
|
|
||||||
if (!newEnvKey.trim()) return;
|
|
||||||
setLocalEdits((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[newEnvKey.trim()]: '',
|
|
||||||
}));
|
|
||||||
setNewEnvKey('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSensitiveKey = (key: string): boolean => {
|
const isSensitiveKey = (key: string): boolean => {
|
||||||
const sensitivePatterns = [
|
const sensitivePatterns = [
|
||||||
/^ANTHROPIC_AUTH_TOKEN$/,
|
/^ANTHROPIC_AUTH_TOKEN$/,
|
||||||
@@ -212,10 +224,206 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
// Reset state when profile changes
|
// Reset state when profile changes
|
||||||
const profileKey = profileName;
|
const profileKey = profileName;
|
||||||
|
|
||||||
|
// Render Left Column Content (Environment + Info + Usage)
|
||||||
|
const renderFriendlyUI = () => (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Tabs defaultValue="env" className="h-full flex flex-col">
|
||||||
|
<div className="px-4 pt-4 shrink-0">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="env" className="flex-1">
|
||||||
|
Environment Variables
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="info" className="flex-1">
|
||||||
|
Info & Usage
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<TabsContent
|
||||||
|
value="env"
|
||||||
|
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Scrollable Environment Variables List */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
|
||||||
|
<>
|
||||||
|
{Object.entries(currentSettings.env).map(([key, value]) => (
|
||||||
|
<div key={key} className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-2 text-muted-foreground">
|
||||||
|
{key}
|
||||||
|
{isSensitiveKey(key) && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
|
||||||
|
sensitive
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{isSensitiveKey(key) ? (
|
||||||
|
<MaskedInput
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||||
|
className="font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||||
|
className="font-mono text-sm h-8"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
|
||||||
|
<p>No environment variables configured.</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">
|
||||||
|
Add variables using the input below or edit the JSON directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Fixed Add Input at Bottom */}
|
||||||
|
<div className="p-4 border-t bg-background shrink-0">
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Add Environment Variable
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
placeholder="VARIABLE_NAME"
|
||||||
|
value={newEnvKey}
|
||||||
|
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
|
||||||
|
className="font-mono text-sm h-8"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={addNewEnvVar}
|
||||||
|
disabled={!newEnvKey.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="info"
|
||||||
|
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
|
||||||
|
>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
{/* Profile Information */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
Profile Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
|
<span className="font-medium text-muted-foreground">Profile Name</span>
|
||||||
|
<span className="font-mono">{data.profile}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
|
<span className="font-medium text-muted-foreground">File Path</span>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||||
|
{data.path}
|
||||||
|
</code>
|
||||||
|
<CopyButton value={data.path} size="icon" className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||||
|
<span className="font-medium text-muted-foreground">Last Modified</span>
|
||||||
|
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||||
|
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Run with profile</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||||
|
ccs {profileName} "prompt"
|
||||||
|
</code>
|
||||||
|
<CopyButton
|
||||||
|
value={`ccs ${profileName} "prompt"`}
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">Set as default</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||||
|
ccs default {profileName}
|
||||||
|
</code>
|
||||||
|
<CopyButton
|
||||||
|
value={`ccs default ${profileName}`}
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render Right Column Content (Raw JSON Editor)
|
||||||
|
const renderRawEditor = () => (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||||
|
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Invalid JSON syntax
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
|
||||||
|
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||||
|
<CodeEditor
|
||||||
|
value={rawJsonContent}
|
||||||
|
onChange={handleRawJsonChange}
|
||||||
|
language="json"
|
||||||
|
minHeight="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={profileKey} className="flex-1 flex flex-col overflow-hidden">
|
<div key={profileKey} className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between">
|
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">{profileName}</h2>
|
<h2 className="text-lg font-semibold">{profileName}</h2>
|
||||||
@@ -243,9 +451,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={
|
disabled={saveMutation.isPending || !hasChanges || !isRawJsonValid}
|
||||||
saveMutation.isPending || !hasChanges || (activeTab === 'raw' && !isRawJsonValid)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? (
|
{saveMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
@@ -268,250 +474,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
|||||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs
|
// Split Layout (40% Left / 60% Right)
|
||||||
value={activeTab}
|
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||||
onValueChange={setActiveTab}
|
{/* Left Column: Friendly UI */}
|
||||||
className="flex-1 flex flex-col overflow-hidden"
|
<div className="flex flex-col overflow-hidden bg-muted/5">{renderFriendlyUI()}</div>
|
||||||
>
|
|
||||||
<div className="px-6 pt-4">
|
{/* Right Column: Raw Editor */}
|
||||||
<TabsList className="w-full justify-start bg-muted/50">
|
<div className="flex flex-col overflow-hidden">
|
||||||
<TabsTrigger value="env" className="gap-1.5">
|
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||||
<Settings className="w-4 h-4" />
|
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||||
Environment
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
</TabsTrigger>
|
Raw Configuration (JSON)
|
||||||
<TabsTrigger value="raw" className="gap-1.5">
|
</span>
|
||||||
<Code2 className="w-4 h-4" />
|
</div>
|
||||||
Raw JSON
|
{renderRawEditor()}
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="usage" className="gap-1.5">
|
|
||||||
<Terminal className="w-4 h-4" />
|
|
||||||
Usage
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="info" className="gap-1.5">
|
|
||||||
<Info className="w-4 h-4" />
|
|
||||||
Info
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Environment Tab */}
|
|
||||||
<TabsContent value="env" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="space-y-6 py-4">
|
|
||||||
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
|
|
||||||
<>
|
|
||||||
{Object.entries(currentSettings.env).map(([key, value]) => (
|
|
||||||
<div key={key} className="space-y-1.5">
|
|
||||||
<Label className="text-sm font-medium flex items-center gap-2">
|
|
||||||
{key}
|
|
||||||
{isSensitiveKey(key) && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
||||||
sensitive
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
{isSensitiveKey(key) ? (
|
|
||||||
<MaskedInput
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => updateEnvValue(key, e.target.value)}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => updateEnvValue(key, e.target.value)}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add new env var */}
|
|
||||||
<div className="pt-4 border-t">
|
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
|
||||||
Add Environment Variable
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<Input
|
|
||||||
placeholder="VARIABLE_NAME"
|
|
||||||
value={newEnvKey}
|
|
||||||
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={addNewEnvVar}
|
|
||||||
disabled={!newEnvKey.trim()}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="py-12 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
|
|
||||||
<p>No environment variables configured.</p>
|
|
||||||
<p className="text-xs mt-1">
|
|
||||||
Add variables using the Raw JSON tab or the form below.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 mt-4 justify-center">
|
|
||||||
<Input
|
|
||||||
placeholder="VARIABLE_NAME"
|
|
||||||
value={newEnvKey}
|
|
||||||
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
|
|
||||||
className="font-mono text-sm max-w-xs"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={addNewEnvVar}
|
|
||||||
disabled={!newEnvKey.trim()}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Raw JSON Tab */}
|
|
||||||
<TabsContent value="raw" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
|
||||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2">
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
Invalid JSON syntax
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 border rounded-md overflow-hidden">
|
|
||||||
<CodeEditor
|
|
||||||
value={rawJsonContent}
|
|
||||||
onChange={handleRawJsonChange}
|
|
||||||
language="json"
|
|
||||||
minHeight="100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Usage Tab */}
|
|
||||||
<TabsContent value="usage" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="py-4 space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">CLI Usage</CardTitle>
|
|
||||||
<CardDescription>Use this profile from the command line</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Run Claude with this profile
|
|
||||||
</Label>
|
|
||||||
<code className="block mt-1 p-3 bg-muted rounded-md text-sm font-mono">
|
|
||||||
ccs {profileName} "your prompt here"
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Start interactive session
|
|
||||||
</Label>
|
|
||||||
<code className="block mt-1 p-3 bg-muted rounded-md text-sm font-mono">
|
|
||||||
ccs {profileName}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">Set as default</Label>
|
|
||||||
<code className="block mt-1 p-3 bg-muted rounded-md text-sm font-mono">
|
|
||||||
ccs default {profileName}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Environment Variables</CardTitle>
|
|
||||||
<CardDescription>Variables set when using this profile</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
|
|
||||||
Object.entries(currentSettings.env).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex items-center gap-2 text-sm">
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
|
||||||
{key}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground font-mono truncate">
|
|
||||||
{isSensitiveKey(key) ? '••••••••' : value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No environment variables set
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Info Tab */}
|
|
||||||
<TabsContent value="info" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="py-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Profile Information</CardTitle>
|
|
||||||
<CardDescription>Details about this configuration file</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{data && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
|
|
||||||
<span className="font-medium text-muted-foreground">Profile Name</span>
|
|
||||||
<span className="font-mono">{data.profile}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
|
|
||||||
<span className="font-medium text-muted-foreground">File Path</span>
|
|
||||||
<code className="bg-muted p-1 rounded text-xs break-all">
|
|
||||||
{data.path}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
|
|
||||||
<span className="font-medium text-muted-foreground">Last Modified</span>
|
|
||||||
<span>{new Date(data.mtime).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
|
|
||||||
<span className="font-medium text-muted-foreground">Variables</span>
|
|
||||||
<span>{Object.keys(currentSettings?.env || {}).length} configured</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
variant?: 'default' | 'outline' | 'ghost' | 'secondary';
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'icon',
|
||||||
|
label = 'Copy to clipboard',
|
||||||
|
}: CopyButtonProps) {
|
||||||
|
const [hasCopied, setHasCopied] = useState(false);
|
||||||
|
|
||||||
|
const onCopy = () => {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
setHasCopied(true);
|
||||||
|
setTimeout(() => setHasCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 relative z-10 text-foreground/70 hover:text-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasCopied ? (
|
||||||
|
<Check className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{hasCopied ? 'Copied!' : label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-2
@@ -26,6 +26,7 @@ import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
|
|||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
|
|
||||||
export function ApiPage() {
|
export function ApiPage() {
|
||||||
const { data, isLoading } = useProfiles();
|
const { data, isLoading } = useProfiles();
|
||||||
@@ -74,7 +75,7 @@ export function ApiPage() {
|
|||||||
const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile);
|
const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-60px)] flex">
|
<div className="h-[calc(100vh-100px)] flex">
|
||||||
{/* Left Panel - Profiles List */}
|
{/* Left Panel - Profiles List */}
|
||||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -248,7 +249,16 @@ function ProfileListItem({
|
|||||||
{/* Profile info */}
|
{/* Profile info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-sm truncate">{profile.name}</div>
|
<div className="font-medium text-sm truncate">{profile.name}</div>
|
||||||
<div className="text-xs text-muted-foreground truncate">{profile.settingsPath}</div>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<div className="text-xs text-muted-foreground truncate flex-1">
|
||||||
|
{profile.settingsPath}
|
||||||
|
</div>
|
||||||
|
<CopyButton
|
||||||
|
value={profile.settingsPath}
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
Reference in new issue
Block a user