mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 06:20:13 +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>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHead key={header.id}>
|
const widthClass =
|
||||||
{header.isPlaceholder
|
{
|
||||||
? null
|
name: 'w-[200px]',
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
type: 'w-[100px]',
|
||||||
</TableHead>
|
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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -17,24 +17,27 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { useDeleteProfile } from '@/hooks/use-profiles';
|
||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
|
|
||||||
interface ProfilesTableProps {
|
interface ProfilesTableProps {
|
||||||
data: Profile[];
|
data: Profile[];
|
||||||
onEdit?: (profile: Profile) => void;
|
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 deleteMutation = useDeleteProfile();
|
||||||
|
|
||||||
const columns: ColumnDef<Profile>[] = [
|
const columns: ColumnDef<Profile>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Name',
|
header: 'Name',
|
||||||
|
size: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'settingsPath',
|
accessorKey: 'settingsPath',
|
||||||
@@ -43,6 +46,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: 'configured',
|
accessorKey: 'configured',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
size: 100,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
|
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
|
||||||
{row.original.configured ? '[OK]' : '[!]'}
|
{row.original.configured ? '[OK]' : '[!]'}
|
||||||
@@ -52,6 +56,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
|
size: 100,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -63,9 +68,16 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
|||||||
{onEdit && (
|
{onEdit && (
|
||||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
Edit
|
Edit Profile
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{onEditSettings && (
|
||||||
|
<DropdownMenuItem onClick={() => onEditSettings(row.original)}>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Edit Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600"
|
className="text-red-600"
|
||||||
onClick={() => deleteMutation.mutate(row.original.name)}
|
onClick={() => deleteMutation.mutate(row.original.name)}
|
||||||
@@ -100,13 +112,30 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHead key={header.id}>
|
const isAction = header.id === 'actions';
|
||||||
{header.isPlaceholder
|
const isStatus = header.id === 'configured';
|
||||||
? null
|
const isName = header.id === 'name';
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</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>
|
<p>
|
||||||
Accounts are isolated Claude instances with separate sessions.
|
Accounts are isolated Claude instances with separate sessions.
|
||||||
<br />
|
<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.
|
accounts via CLI.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+23
-1
@@ -8,12 +8,15 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { ProfilesTable } from '@/components/profiles-table';
|
import { ProfilesTable } from '@/components/profiles-table';
|
||||||
import { ProfileDialog } from '@/components/profile-dialog';
|
import { ProfileDialog } from '@/components/profile-dialog';
|
||||||
|
import { SettingsDialog } from '@/components/settings-dialog';
|
||||||
import { useProfiles } from '@/hooks/use-profiles';
|
import { useProfiles } from '@/hooks/use-profiles';
|
||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
|
|
||||||
export function ApiPage() {
|
export function ApiPage() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
|
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
|
||||||
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
|
const [settingsProfileName, setSettingsProfileName] = useState<string | null>(null);
|
||||||
const { data, isLoading } = useProfiles();
|
const { data, isLoading } = useProfiles();
|
||||||
|
|
||||||
const handleEdit = (profile: Profile) => {
|
const handleEdit = (profile: Profile) => {
|
||||||
@@ -21,11 +24,21 @@ export function ApiPage() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditSettings = (profile: Profile) => {
|
||||||
|
setSettingsProfileName(profile.name);
|
||||||
|
setSettingsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCloseDialog = () => {
|
const handleCloseDialog = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setEditingProfile(null);
|
setEditingProfile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseSettingsDialog = () => {
|
||||||
|
setSettingsDialogOpen(false);
|
||||||
|
setSettingsProfileName(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -44,10 +57,19 @@ export function ApiPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground">Loading profiles...</div>
|
<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} />
|
<ProfileDialog open={dialogOpen} onClose={handleCloseDialog} profile={editingProfile} />
|
||||||
|
<SettingsDialog
|
||||||
|
open={settingsDialogOpen}
|
||||||
|
onClose={handleCloseSettingsDialog}
|
||||||
|
profileName={settingsProfileName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user