feat: add connection management UI

Add TypeScript types, typed Tauri invoke wrappers, Zustand store,
TanStack Query hooks, and connection components: ConnectionDialog
(create/edit/test), ConnectionList (sheet panel), ConnectionSelector
(toolbar dropdown).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:06:35 +03:00
parent 9b675babd5
commit 734b84b525
10 changed files with 878 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
import { toast } from "sonner";
import type { ConnectionConfig } from "@/types";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
connection?: ConnectionConfig | null;
}
const emptyConfig: ConnectionConfig = {
id: "",
name: "",
host: "localhost",
port: 5432,
user: "postgres",
password: "",
database: "postgres",
ssl_mode: "prefer",
color: undefined,
};
export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
const [form, setForm] = useState<ConnectionConfig>(emptyConfig);
const saveMutation = useSaveConnection();
const testMutation = useTestConnection();
useEffect(() => {
if (open) {
setForm(
connection ?? { ...emptyConfig, id: crypto.randomUUID() }
);
}
}, [open, connection]);
const update = (field: keyof ConnectionConfig, value: string | number) => {
setForm((f) => ({ ...f, [field]: value }));
};
const handleTest = () => {
testMutation.mutate(form, {
onSuccess: (version) => {
toast.success("Connection successful", { description: version });
},
onError: (err) => {
toast.error("Connection failed", { description: String(err) });
},
});
};
const handleSave = () => {
if (!form.name.trim()) {
toast.error("Name is required");
return;
}
saveMutation.mutate(form, {
onSuccess: () => {
toast.success("Connection saved");
onOpenChange(false);
},
onError: (err) => {
toast.error("Save failed", { description: String(err) });
},
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>
{connection ? "Edit Connection" : "New Connection"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Name
</label>
<Input
className="col-span-3"
value={form.name}
onChange={(e) => update("name", e.target.value)}
placeholder="My Database"
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Host
</label>
<Input
className="col-span-3"
value={form.host}
onChange={(e) => update("host", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Port
</label>
<Input
className="col-span-3"
type="number"
value={form.port}
onChange={(e) => update("port", parseInt(e.target.value) || 5432)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
User
</label>
<Input
className="col-span-3"
value={form.user}
onChange={(e) => update("user", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Password
</label>
<Input
className="col-span-3"
type="password"
value={form.password}
onChange={(e) => update("password", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Database
</label>
<Input
className="col-span-3"
value={form.database}
onChange={(e) => update("database", e.target.value)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
SSL Mode
</label>
<Select
value={form.ssl_mode ?? "prefer"}
onValueChange={(v) => update("ssl_mode", v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disable">Disable</SelectItem>
<SelectItem value="prefer">Prefer</SelectItem>
<SelectItem value="require">Require</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Test
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import {
useConnections,
useDeleteConnection,
useConnect,
useDisconnect,
} from "@/hooks/use-connections";
import { useAppStore } from "@/stores/app-store";
import { toast } from "sonner";
import {
Database,
Plug,
Unplug,
Pencil,
Trash2,
Plus,
Loader2,
} from "lucide-react";
import type { ConnectionConfig } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onEdit: (conn: ConnectionConfig) => void;
onNew: () => void;
}
export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
const { data: connections } = useConnections();
const deleteMutation = useDeleteConnection();
const connectMutation = useConnect();
const disconnectMutation = useDisconnect();
const { connectedIds, activeConnectionId } = useAppStore();
const handleConnect = (conn: ConnectionConfig) => {
connectMutation.mutate(conn, {
onSuccess: () => {
toast.success(`Connected to ${conn.name}`);
onOpenChange(false);
},
onError: (err) => {
toast.error("Connection failed", { description: String(err) });
},
});
};
const handleDisconnect = (id: string) => {
disconnectMutation.mutate(id, {
onSuccess: () => toast.success("Disconnected"),
onError: (err) =>
toast.error("Disconnect failed", { description: String(err) }),
});
};
const handleDelete = (id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => toast.success("Connection deleted"),
onError: (err) =>
toast.error("Delete failed", { description: String(err) }),
});
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-[400px] max-w-[90vw] p-0" showCloseButton={false}>
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle className="flex items-center justify-between">
Connections
<Button size="sm" variant="outline" onClick={onNew}>
<Plus className="mr-1 h-4 w-4" />
New
</Button>
</SheetTitle>
</SheetHeader>
<div className="overflow-y-auto overflow-x-hidden" style={{ height: "calc(100vh - 80px)" }}>
<div className="space-y-2 px-4 pb-4">
{connections?.map((conn) => {
const isConnected = connectedIds.has(conn.id);
const isActive = activeConnectionId === conn.id;
return (
<div
key={conn.id}
className={`flex items-center gap-2 overflow-hidden rounded-md border p-3 ${
isActive ? "border-primary bg-accent" : ""
}`}
>
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{conn.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{conn.host}:{conn.port}/{conn.database}
</div>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{isConnected ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleDisconnect(conn.id)}
disabled={disconnectMutation.isPending}
>
<Unplug className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleConnect(conn)}
disabled={connectMutation.isPending}
>
{connectMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plug className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onEdit(conn)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(conn.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
{(!connections || connections.length === 0) && (
<div className="py-8 text-center text-sm text-muted-foreground">
No saved connections.
<br />
Click "New" to add one.
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,41 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useConnections } from "@/hooks/use-connections";
import { useAppStore } from "@/stores/app-store";
export function ConnectionSelector() {
const { data: connections } = useConnections();
const { activeConnectionId, setActiveConnectionId, connectedIds } =
useAppStore();
const connectedList = connections?.filter((c) => connectedIds.has(c.id)) ?? [];
if (connectedList.length === 0) {
return (
<div className="text-xs text-muted-foreground px-2">Not connected</div>
);
}
return (
<Select
value={activeConnectionId ?? undefined}
onValueChange={setActiveConnectionId}
>
<SelectTrigger className="h-7 w-[200px] text-xs">
<SelectValue placeholder="Select connection" />
</SelectTrigger>
<SelectContent>
{connectedList.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}