mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 04:18:19 +00:00
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
/**
|
|
* Profile Editor Component
|
|
* Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON)
|
|
*/
|
|
|
|
/* eslint-disable react-refresh/only-export-components */
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
import { Loader2, Code2, RefreshCw } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { HeaderSection } from './header-section';
|
|
import { FriendlyUISection } from './friendly-ui-section';
|
|
import { RawEditorSection } from './raw-editor-section';
|
|
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
|
|
import { api, type CliTarget } from '@/lib/api-client';
|
|
import i18n from '@/lib/i18n';
|
|
|
|
export function ProfileEditor({
|
|
profileName,
|
|
profileTarget,
|
|
onDelete,
|
|
onHasChangesUpdate,
|
|
}: ProfileEditorProps) {
|
|
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
|
const [conflictDialog, setConflictDialog] = useState(false);
|
|
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
|
const [newEnvKey, setNewEnvKey] = useState('');
|
|
const [newEnvValue, setNewEnvValue] = useState('');
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch settings for selected profile
|
|
const { data, isLoading, isError, refetch } = useQuery<SettingsResponse>({
|
|
queryKey: ['settings', profileName],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/settings/${profileName}/raw`);
|
|
if (!res.ok) throw new Error(`Failed to load settings: ${res.status}`);
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
const settings = data?.settings;
|
|
|
|
// Derive current settings by merging original data with local edits
|
|
const currentSettings = useMemo((): Settings | undefined => {
|
|
if (rawJsonEdits !== null) {
|
|
try {
|
|
return JSON.parse(rawJsonEdits);
|
|
} catch {
|
|
// Fall back to settings merge
|
|
}
|
|
}
|
|
if (!settings) return undefined;
|
|
return { ...settings, env: { ...settings.env, ...localEdits } };
|
|
}, [settings, localEdits, rawJsonEdits]);
|
|
|
|
// Compute raw JSON content
|
|
const computedRawJsonContent = useMemo(() => {
|
|
if (rawJsonEdits !== null) return rawJsonEdits;
|
|
if (settings) return JSON.stringify(settings, null, 2);
|
|
return '';
|
|
}, [rawJsonEdits, settings]);
|
|
|
|
const handleRawJsonChange = useCallback((value: string) => {
|
|
setRawJsonEdits(value);
|
|
}, []);
|
|
|
|
// Sync Visual Editor changes to Raw JSON
|
|
const updateEnvValue = (key: string, value: string) => {
|
|
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
|
setLocalEdits((prev) => ({ ...prev, [key]: value }));
|
|
setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2));
|
|
};
|
|
|
|
// Bulk update multiple env vars at once (avoids race conditions)
|
|
const updateEnvBulk = (env: Record<string, string>) => {
|
|
const newEnv = { ...(currentSettings?.env || {}), ...env };
|
|
setLocalEdits((prev) => ({ ...prev, ...env }));
|
|
setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2));
|
|
};
|
|
|
|
const addNewEnvVar = () => {
|
|
if (!newEnvKey.trim()) return;
|
|
const key = newEnvKey.trim();
|
|
const value = newEnvValue;
|
|
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
|
setLocalEdits((prev) => ({ ...prev, [key]: value }));
|
|
setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2));
|
|
setNewEnvKey('');
|
|
setNewEnvValue('');
|
|
};
|
|
|
|
// Computed validity and changes check
|
|
const computedIsRawJsonValid = useMemo(() => {
|
|
try {
|
|
JSON.parse(computedRawJsonContent);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, [computedRawJsonContent]);
|
|
|
|
const computedHasChanges = useMemo(() => {
|
|
if (rawJsonEdits !== null) return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
|
return Object.keys(localEdits).length > 0;
|
|
}, [rawJsonEdits, localEdits, settings]);
|
|
|
|
// Check for missing required fields (informational warning)
|
|
const missingRequiredFields = useMemo(() => {
|
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
|
const env = currentSettings?.env || {};
|
|
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
|
}, [currentSettings]);
|
|
|
|
// Notify parent of hasChanges state
|
|
useEffect(() => {
|
|
onHasChangesUpdate?.(computedHasChanges);
|
|
}, [computedHasChanges, onHasChangesUpdate]);
|
|
|
|
// Save mutation
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
let settingsToSave: Settings;
|
|
try {
|
|
settingsToSave = JSON.parse(computedRawJsonContent);
|
|
} catch {
|
|
settingsToSave = { ...data?.settings, env: { ...data?.settings?.env, ...localEdits } };
|
|
}
|
|
|
|
const res = await fetch(`/api/settings/${profileName}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ settings: settingsToSave, expectedMtime: data?.mtime }),
|
|
});
|
|
|
|
if (res.status === 409) throw new Error('CONFLICT');
|
|
if (!res.ok) throw new Error('Failed to save');
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
|
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
|
setLocalEdits({});
|
|
setRawJsonEdits(null);
|
|
toast.success(i18n.t('commonToast.settingsSaved'));
|
|
},
|
|
onError: (error: Error) => {
|
|
if (error.message === 'CONFLICT') setConflictDialog(true);
|
|
else toast.error(error.message);
|
|
},
|
|
});
|
|
|
|
const targetMutation = useMutation<CliTarget, Error, CliTarget>({
|
|
mutationFn: async (target: CliTarget) => {
|
|
await api.profiles.update(profileName, { target });
|
|
return target;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
|
toast.success(i18n.t('commonToast.defaultTargetUpdated'));
|
|
},
|
|
onError: (error: Error, target: CliTarget) => {
|
|
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
|
|
const suffix = error.message.trim() ? `: ${error.message}` : '';
|
|
toast.error(i18n.t('commonToast.failedUpdateDefaultTarget', { target: targetLabel, suffix }));
|
|
},
|
|
});
|
|
|
|
const resolvedTarget: CliTarget = profileTarget || 'claude';
|
|
const isHeaderMutationPending = saveMutation.isPending || targetMutation.isPending;
|
|
|
|
const handleConflictResolve = async (overwrite: boolean) => {
|
|
setConflictDialog(false);
|
|
if (overwrite) {
|
|
await refetch();
|
|
saveMutation.mutate();
|
|
} else {
|
|
setLocalEdits({});
|
|
setRawJsonEdits(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div key={profileName} className="flex-1 flex flex-col overflow-hidden">
|
|
<HeaderSection
|
|
profileName={profileName}
|
|
target={resolvedTarget}
|
|
data={data}
|
|
settings={currentSettings}
|
|
isLoading={isLoading}
|
|
isSaving={saveMutation.isPending}
|
|
isTargetSaving={targetMutation.isPending}
|
|
hasChanges={computedHasChanges}
|
|
isRawJsonValid={computedIsRawJsonValid}
|
|
onTargetChange={(target) => {
|
|
if (isHeaderMutationPending) return;
|
|
if (target === resolvedTarget) return;
|
|
targetMutation.mutate(target);
|
|
}}
|
|
onRefresh={() => {
|
|
if (isHeaderMutationPending) return;
|
|
refetch();
|
|
}}
|
|
onDelete={onDelete}
|
|
onSave={() => {
|
|
if (isHeaderMutationPending) return;
|
|
saveMutation.mutate();
|
|
}}
|
|
/>
|
|
|
|
{isLoading ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
|
</div>
|
|
) : isError ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="text-center space-y-3">
|
|
<p className="text-sm text-muted-foreground">Failed to load settings.</p>
|
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
|
<RefreshCw className="w-4 h-4 mr-1" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
|
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
|
|
<FriendlyUISection
|
|
profileName={profileName}
|
|
target={resolvedTarget}
|
|
data={data}
|
|
currentSettings={currentSettings}
|
|
newEnvKey={newEnvKey}
|
|
newEnvValue={newEnvValue}
|
|
onNewEnvKeyChange={setNewEnvKey}
|
|
onNewEnvValueChange={setNewEnvValue}
|
|
onEnvValueChange={updateEnvValue}
|
|
onEnvBulkChange={updateEnvBulk}
|
|
onAddEnvVar={addNewEnvVar}
|
|
/>
|
|
</div>
|
|
<div className="flex min-h-0 min-w-0 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>
|
|
<RawEditorSection
|
|
rawJsonContent={computedRawJsonContent}
|
|
isRawJsonValid={computedIsRawJsonValid}
|
|
rawJsonEdits={rawJsonEdits}
|
|
settings={settings}
|
|
onChange={handleRawJsonChange}
|
|
missingRequiredFields={missingRequiredFields}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={conflictDialog}
|
|
title="File Modified Externally"
|
|
description="Overwrite with your changes or discard?"
|
|
confirmText="Overwrite"
|
|
variant="destructive"
|
|
onConfirm={() => handleConflictResolve(true)}
|
|
onCancel={() => handleConflictResolve(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Re-exports
|
|
export { EnvEditorSection } from './env-editor-section';
|
|
export { InfoSection } from './info-section';
|
|
export { RawEditorSection } from './raw-editor-section';
|
|
export { HeaderSection } from './header-section';
|
|
export { FriendlyUISection } from './friendly-ui-section';
|
|
export { useProfileEditor } from './use-profile-editor';
|
|
export { isSensitiveKey, isOpenRouterProfile, extractTierMapping, applyTierMapping } from './utils';
|
|
export type { Settings, SettingsResponse, ProfileEditorProps } from './types';
|