mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(ui): improve table column widths and spacing
This commit is contained in:
@@ -104,13 +104,24 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
{headerGroup.headers.map((header) => {
|
||||
const widthClass =
|
||||
{
|
||||
name: 'w-[200px]',
|
||||
type: 'w-[100px]',
|
||||
created: 'w-[150px]',
|
||||
last_used: 'w-[150px]',
|
||||
actions: 'w-[120px]',
|
||||
}[header.id] || 'w-auto';
|
||||
|
||||
return (
|
||||
<TableHead key={header.id} className={widthClass}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
@@ -17,24 +17,27 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Trash2, Edit } from 'lucide-react';
|
||||
import { MoreHorizontal, Trash2, Edit, Settings } from 'lucide-react';
|
||||
import { useDeleteProfile } from '@/hooks/use-profiles';
|
||||
import type { Profile } from '@/lib/api-client';
|
||||
|
||||
interface ProfilesTableProps {
|
||||
data: Profile[];
|
||||
onEdit?: (profile: Profile) => void;
|
||||
onEditSettings?: (profile: Profile) => void;
|
||||
}
|
||||
|
||||
export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
||||
export function ProfilesTable({ data, onEdit, onEditSettings }: ProfilesTableProps) {
|
||||
const deleteMutation = useDeleteProfile();
|
||||
|
||||
const columns: ColumnDef<Profile>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'settingsPath',
|
||||
@@ -43,6 +46,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
||||
{
|
||||
accessorKey: 'configured',
|
||||
header: 'Status',
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
|
||||
{row.original.configured ? '[OK]' : '[!]'}
|
||||
@@ -52,6 +56,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
size: 100,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -63,9 +68,16 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
||||
{onEdit && (
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
Edit Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onEditSettings && (
|
||||
<DropdownMenuItem onClick={() => onEditSettings(row.original)}>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Edit Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => deleteMutation.mutate(row.original.name)}
|
||||
@@ -100,13 +112,30 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isAction = header.id === 'actions';
|
||||
const isStatus = header.id === 'configured';
|
||||
const isName = header.id === 'name';
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
isAction
|
||||
? 'w-[50px]'
|
||||
: isStatus
|
||||
? 'w-[100px]'
|
||||
: isName
|
||||
? 'w-[200px]'
|
||||
: 'w-auto'
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Settings Dialog Component
|
||||
* Reusable dialog for editing profile environment variables
|
||||
* Features: masked inputs for sensitive keys, conflict detection, save/cancel
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Save, X, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Settings {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SettingsResponse {
|
||||
profile: string;
|
||||
settings: Settings;
|
||||
mtime: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that manages local edits state
|
||||
* Gets unmounted/remounted via key prop when dialog closes/opens
|
||||
*/
|
||||
function SettingsDialogContent({
|
||||
profileName,
|
||||
onClose,
|
||||
}: {
|
||||
profileName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch settings for selected profile
|
||||
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
|
||||
queryKey: ['settings', profileName],
|
||||
queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()),
|
||||
});
|
||||
|
||||
// Derive current settings by merging original data with local edits
|
||||
const currentSettings = useMemo((): Settings | undefined => {
|
||||
const settings = data?.settings;
|
||||
if (!settings) return undefined;
|
||||
return {
|
||||
...settings,
|
||||
env: {
|
||||
...settings.env,
|
||||
...localEdits,
|
||||
},
|
||||
};
|
||||
}, [data?.settings, localEdits]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const settingsToSave: Settings = {
|
||||
...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'] });
|
||||
toast.success('Settings saved');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error.message === 'CONFLICT') {
|
||||
setConflictDialog(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
const handleConflictResolve = async (overwrite: boolean) => {
|
||||
setConflictDialog(false);
|
||||
if (overwrite) {
|
||||
// Refetch to get new mtime, then save
|
||||
await refetch();
|
||||
saveMutation.mutate();
|
||||
} else {
|
||||
// Discard local changes and close
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const updateEnvValue = (key: string, value: string) => {
|
||||
setLocalEdits((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const isSensitiveKey = (key: string): boolean => {
|
||||
return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Environment Settings: {profileName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit environment variables for this profile. Sensitive keys are masked.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
) : currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(currentSettings.env).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<Label>{key}</Label>
|
||||
{isSensitiveKey(key) ? (
|
||||
<MaskedInput value={value} onChange={(e) => updateEnvValue(key, e.target.value)} />
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data && (
|
||||
<div className="pt-4 text-xs text-muted-foreground border-t">
|
||||
<p>Path: {data.path}</p>
|
||||
<p>Last modified: {new Date(data.mtime).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<X className="w-4 h-4 mr-2" /> Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" /> Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No environment variables configured for this profile.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) {
|
||||
// Handle dialog open/close state changes
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
{/* Key prop ensures fresh state on each open */}
|
||||
{open && profileName && (
|
||||
<SettingsDialogContent
|
||||
key={`${profileName}-${open}`}
|
||||
profileName={profileName}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function AccountsPage() {
|
||||
<p>
|
||||
Accounts are isolated Claude instances with separate sessions.
|
||||
<br />
|
||||
Use <code className="bg-muted px-1 rounded">ccs login <name></code> to add new
|
||||
Use <code className="bg-muted px-1 rounded">ccs auth create <name></code> to add new
|
||||
accounts via CLI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+23
-1
@@ -8,12 +8,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { ProfilesTable } from '@/components/profiles-table';
|
||||
import { ProfileDialog } from '@/components/profile-dialog';
|
||||
import { SettingsDialog } from '@/components/settings-dialog';
|
||||
import { useProfiles } from '@/hooks/use-profiles';
|
||||
import type { Profile } from '@/lib/api-client';
|
||||
|
||||
export function ApiPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsProfileName, setSettingsProfileName] = useState<string | null>(null);
|
||||
const { data, isLoading } = useProfiles();
|
||||
|
||||
const handleEdit = (profile: Profile) => {
|
||||
@@ -21,11 +24,21 @@ export function ApiPage() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSettings = (profile: Profile) => {
|
||||
setSettingsProfileName(profile.name);
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingProfile(null);
|
||||
};
|
||||
|
||||
const handleCloseSettingsDialog = () => {
|
||||
setSettingsDialogOpen(false);
|
||||
setSettingsProfileName(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -44,10 +57,19 @@ export function ApiPage() {
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground">Loading profiles...</div>
|
||||
) : (
|
||||
<ProfilesTable data={data?.profiles || []} onEdit={handleEdit} />
|
||||
<ProfilesTable
|
||||
data={data?.profiles || []}
|
||||
onEdit={handleEdit}
|
||||
onEditSettings={handleEditSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProfileDialog open={dialogOpen} onClose={handleCloseDialog} profile={editingProfile} />
|
||||
<SettingsDialog
|
||||
open={settingsDialogOpen}
|
||||
onClose={handleCloseSettingsDialog}
|
||||
profileName={settingsProfileName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user