mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
feat(api-profile-ux): implement tabbed profile editor and fix disclaimer visibility
This commit is contained in:
+7
-5
@@ -43,16 +43,18 @@ function Layout() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<header className="flex h-12 items-center justify-end px-4 border-b">
|
||||
<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 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<ConnectionIndicator />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<div className="flex-1 overflow-auto pb-12">
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
<LocalhostDisclaimer />
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import { Shield, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useSidebar } from '@/hooks/use-sidebar';
|
||||
|
||||
export function LocalhostDisclaimer() {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { state, isMobile } = useSidebar();
|
||||
|
||||
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 (
|
||||
<div
|
||||
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="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">
|
||||
<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">
|
||||
<Shield className="w-4 h-4 flex-shrink-0" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -9,24 +9,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { MaskedInput } from '@/components/ui/masked-input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
Code2,
|
||||
Settings,
|
||||
Info,
|
||||
Terminal,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react';
|
||||
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
|
||||
const CodeEditor = lazy(() =>
|
||||
@@ -53,7 +42,6 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('env');
|
||||
const [newEnvKey, setNewEnvKey] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -80,7 +68,18 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
}, []);
|
||||
|
||||
// Derive current settings by merging original data with local edits
|
||||
// Prioritize rawJsonEdits if available
|
||||
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;
|
||||
return {
|
||||
...settings,
|
||||
@@ -89,7 +88,38 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
...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
|
||||
const isRawJsonValid = useMemo(() => {
|
||||
@@ -103,24 +133,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (activeTab === 'raw') {
|
||||
return rawJsonEdits !== null;
|
||||
if (rawJsonEdits !== null) {
|
||||
return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
||||
}
|
||||
return Object.keys(localEdits).length > 0;
|
||||
}, [activeTab, rawJsonEdits, localEdits]);
|
||||
}, [rawJsonEdits, localEdits, settings]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let settingsToSave: Settings;
|
||||
|
||||
if (activeTab === 'raw') {
|
||||
try {
|
||||
settingsToSave = JSON.parse(rawJsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// Always save from rawJsonContent as it's the source of truth
|
||||
settingsToSave = JSON.parse(rawJsonContent);
|
||||
} catch {
|
||||
// Fallback (should typically not happen if validation is correct)
|
||||
settingsToSave = {
|
||||
...data?.settings,
|
||||
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 sensitivePatterns = [
|
||||
/^ANTHROPIC_AUTH_TOKEN$/,
|
||||
@@ -212,10 +224,206 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
// Reset state when profile changes
|
||||
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 (
|
||||
<div key={profileKey} className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 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 className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{profileName}</h2>
|
||||
@@ -243,9 +451,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saveMutation.isPending || !hasChanges || (activeTab === 'raw' && !isRawJsonValid)
|
||||
}
|
||||
disabled={saveMutation.isPending || !hasChanges || !isRawJsonValid}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
@@ -268,250 +474,22 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="w-full justify-start bg-muted/50">
|
||||
<TabsTrigger value="env" className="gap-1.5">
|
||||
<Settings className="w-4 h-4" />
|
||||
Environment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="raw" className="gap-1.5">
|
||||
<Code2 className="w-4 h-4" />
|
||||
Raw JSON
|
||||
</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>
|
||||
// Split Layout (40% Left / 60% Right)
|
||||
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
{/* Left Column: Friendly UI */}
|
||||
<div className="flex flex-col overflow-hidden bg-muted/5">{renderFriendlyUI()}</div>
|
||||
|
||||
{/* Right Column: Raw Editor */}
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Raw Configuration (JSON)
|
||||
</span>
|
||||
</div>
|
||||
{renderRawEditor()}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 type { Profile } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
|
||||
export function ApiPage() {
|
||||
const { data, isLoading } = useProfiles();
|
||||
@@ -74,7 +75,7 @@ export function ApiPage() {
|
||||
const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-60px)] flex">
|
||||
<div className="h-[calc(100vh-100px)] flex">
|
||||
{/* Left Panel - Profiles List */}
|
||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||
{/* Header */}
|
||||
@@ -248,7 +249,16 @@ function ProfileListItem({
|
||||
{/* Profile info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
Reference in New Issue
Block a user