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:
199
src/components/connections/ConnectionDialog.tsx
Normal file
199
src/components/connections/ConnectionDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/components/connections/ConnectionList.tsx
Normal file
162
src/components/connections/ConnectionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/connections/ConnectionSelector.tsx
Normal file
41
src/components/connections/ConnectionSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/hooks/use-connections.ts
Normal file
90
src/hooks/use-connections.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getConnections,
|
||||||
|
saveConnection,
|
||||||
|
deleteConnection,
|
||||||
|
testConnection,
|
||||||
|
connectDb,
|
||||||
|
disconnectDb,
|
||||||
|
} from "@/lib/tauri";
|
||||||
|
import { useAppStore } from "@/stores/app-store";
|
||||||
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
|
||||||
|
export function useConnections() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["connections"],
|
||||||
|
queryFn: getConnections,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveConnection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (config: ConnectionConfig) => saveConnection(config),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["connections"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteConnection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { removeConnectedId, setActiveConnectionId, activeConnectionId } =
|
||||||
|
useAppStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => deleteConnection(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["connections"] });
|
||||||
|
removeConnectedId(id);
|
||||||
|
if (activeConnectionId === id) {
|
||||||
|
setActiveConnectionId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestConnection() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (config: ConnectionConfig) => testConnection(config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnect() {
|
||||||
|
const { addConnectedId, setActiveConnectionId, setPgVersion, setCurrentDatabase } =
|
||||||
|
useAppStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (config: ConnectionConfig) => {
|
||||||
|
await connectDb(config);
|
||||||
|
const version = await testConnection(config);
|
||||||
|
return { id: config.id, version, database: config.database };
|
||||||
|
},
|
||||||
|
onSuccess: ({ id, version, database }) => {
|
||||||
|
addConnectedId(id);
|
||||||
|
setActiveConnectionId(id);
|
||||||
|
setPgVersion(version);
|
||||||
|
setCurrentDatabase(database);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisconnect() {
|
||||||
|
const {
|
||||||
|
removeConnectedId,
|
||||||
|
setActiveConnectionId,
|
||||||
|
activeConnectionId,
|
||||||
|
setPgVersion,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => disconnectDb(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
removeConnectedId(id);
|
||||||
|
if (activeConnectionId === id) {
|
||||||
|
setActiveConnectionId(null);
|
||||||
|
setPgVersion(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/hooks/use-query-execution.ts
Normal file
14
src/hooks/use-query-execution.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { executeQuery } from "@/lib/tauri";
|
||||||
|
|
||||||
|
export function useQueryExecution() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
connectionId,
|
||||||
|
sql,
|
||||||
|
}: {
|
||||||
|
connectionId: string;
|
||||||
|
sql: string;
|
||||||
|
}) => executeQuery(connectionId, sql),
|
||||||
|
});
|
||||||
|
}
|
||||||
74
src/hooks/use-schema.ts
Normal file
74
src/hooks/use-schema.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
listDatabases,
|
||||||
|
listSchemas,
|
||||||
|
listTables,
|
||||||
|
listViews,
|
||||||
|
listFunctions,
|
||||||
|
listSequences,
|
||||||
|
switchDatabase,
|
||||||
|
} from "@/lib/tauri";
|
||||||
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
|
||||||
|
export function useDatabases(connectionId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["databases", connectionId],
|
||||||
|
queryFn: () => listDatabases(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSwitchDatabase() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ config, database }: { config: ConnectionConfig; database: string }) =>
|
||||||
|
switchDatabase(config, database),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["schemas"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tables"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["views"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["functions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["sequences"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSchemas(connectionId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["schemas", connectionId],
|
||||||
|
queryFn: () => listSchemas(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTables(connectionId: string | null, schema: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["tables", connectionId, schema],
|
||||||
|
queryFn: () => listTables(connectionId!, schema),
|
||||||
|
enabled: !!connectionId && !!schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViews(connectionId: string | null, schema: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["views", connectionId, schema],
|
||||||
|
queryFn: () => listViews(connectionId!, schema),
|
||||||
|
enabled: !!connectionId && !!schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFunctions(connectionId: string | null, schema: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["functions", connectionId, schema],
|
||||||
|
queryFn: () => listFunctions(connectionId!, schema),
|
||||||
|
enabled: !!connectionId && !!schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSequences(connectionId: string | null, schema: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["sequences", connectionId, schema],
|
||||||
|
queryFn: () => listSequences(connectionId!, schema),
|
||||||
|
enabled: !!connectionId && !!schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
25
src/hooks/use-table-data.ts
Normal file
25
src/hooks/use-table-data.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getTableData } from "@/lib/tauri";
|
||||||
|
|
||||||
|
export function useTableData(params: {
|
||||||
|
connectionId: string | null;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: string;
|
||||||
|
filter?: string;
|
||||||
|
}) {
|
||||||
|
const { connectionId, ...rest } = params;
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["table-data", connectionId, rest],
|
||||||
|
queryFn: () =>
|
||||||
|
getTableData({
|
||||||
|
connectionId: connectionId!,
|
||||||
|
...rest,
|
||||||
|
}),
|
||||||
|
enabled: !!connectionId && !!params.schema && !!params.table,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
132
src/lib/tauri.ts
Normal file
132
src/lib/tauri.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type {
|
||||||
|
ConnectionConfig,
|
||||||
|
QueryResult,
|
||||||
|
PaginatedQueryResult,
|
||||||
|
SchemaObject,
|
||||||
|
ColumnInfo,
|
||||||
|
ConstraintInfo,
|
||||||
|
IndexInfo,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
// Connections
|
||||||
|
export const getConnections = () =>
|
||||||
|
invoke<ConnectionConfig[]>("get_connections");
|
||||||
|
|
||||||
|
export const saveConnection = (config: ConnectionConfig) =>
|
||||||
|
invoke<void>("save_connection", { config });
|
||||||
|
|
||||||
|
export const deleteConnection = (id: string) =>
|
||||||
|
invoke<void>("delete_connection", { id });
|
||||||
|
|
||||||
|
export const testConnection = (config: ConnectionConfig) =>
|
||||||
|
invoke<string>("test_connection", { config });
|
||||||
|
|
||||||
|
export const connectDb = (config: ConnectionConfig) =>
|
||||||
|
invoke<void>("connect", { config });
|
||||||
|
|
||||||
|
export const disconnectDb = (id: string) =>
|
||||||
|
invoke<void>("disconnect", { id });
|
||||||
|
|
||||||
|
export const switchDatabase = (config: ConnectionConfig, database: string) =>
|
||||||
|
invoke<void>("switch_database", { config, database });
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
export const executeQuery = (connectionId: string, sql: string) =>
|
||||||
|
invoke<QueryResult>("execute_query", { connectionId, sql });
|
||||||
|
|
||||||
|
// Schema
|
||||||
|
export const listDatabases = (connectionId: string) =>
|
||||||
|
invoke<string[]>("list_databases", { connectionId });
|
||||||
|
|
||||||
|
export const listSchemas = (connectionId: string) =>
|
||||||
|
invoke<string[]>("list_schemas", { connectionId });
|
||||||
|
|
||||||
|
export const listTables = (connectionId: string, schema: string) =>
|
||||||
|
invoke<SchemaObject[]>("list_tables", { connectionId, schema });
|
||||||
|
|
||||||
|
export const listViews = (connectionId: string, schema: string) =>
|
||||||
|
invoke<SchemaObject[]>("list_views", { connectionId, schema });
|
||||||
|
|
||||||
|
export const listFunctions = (connectionId: string, schema: string) =>
|
||||||
|
invoke<SchemaObject[]>("list_functions", { connectionId, schema });
|
||||||
|
|
||||||
|
export const listIndexes = (connectionId: string, schema: string) =>
|
||||||
|
invoke<SchemaObject[]>("list_indexes", { connectionId, schema });
|
||||||
|
|
||||||
|
export const listSequences = (connectionId: string, schema: string) =>
|
||||||
|
invoke<SchemaObject[]>("list_sequences", { connectionId, schema });
|
||||||
|
|
||||||
|
export const getTableColumns = (
|
||||||
|
connectionId: string,
|
||||||
|
schema: string,
|
||||||
|
table: string
|
||||||
|
) => invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
|
||||||
|
|
||||||
|
export const getTableConstraints = (
|
||||||
|
connectionId: string,
|
||||||
|
schema: string,
|
||||||
|
table: string
|
||||||
|
) =>
|
||||||
|
invoke<ConstraintInfo[]>("get_table_constraints", {
|
||||||
|
connectionId,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getTableIndexes = (
|
||||||
|
connectionId: string,
|
||||||
|
schema: string,
|
||||||
|
table: string
|
||||||
|
) => invoke<IndexInfo[]>("get_table_indexes", { connectionId, schema, table });
|
||||||
|
|
||||||
|
// Data
|
||||||
|
export const getTableData = (params: {
|
||||||
|
connectionId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: string;
|
||||||
|
filter?: string;
|
||||||
|
}) => invoke<PaginatedQueryResult>("get_table_data", params);
|
||||||
|
|
||||||
|
export const updateRow = (params: {
|
||||||
|
connectionId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
pkColumns: string[];
|
||||||
|
pkValues: unknown[];
|
||||||
|
column: string;
|
||||||
|
value: unknown;
|
||||||
|
}) => invoke<void>("update_row", params);
|
||||||
|
|
||||||
|
export const insertRow = (params: {
|
||||||
|
connectionId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
columns: string[];
|
||||||
|
values: unknown[];
|
||||||
|
}) => invoke<void>("insert_row", params);
|
||||||
|
|
||||||
|
export const deleteRows = (params: {
|
||||||
|
connectionId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
pkColumns: string[];
|
||||||
|
pkValuesList: unknown[][];
|
||||||
|
}) => invoke<number>("delete_rows", params);
|
||||||
|
|
||||||
|
// Export
|
||||||
|
export const exportCsv = (
|
||||||
|
path: string,
|
||||||
|
columns: string[],
|
||||||
|
rows: unknown[][]
|
||||||
|
) => invoke<void>("export_csv", { path, columns, rows });
|
||||||
|
|
||||||
|
export const exportJson = (
|
||||||
|
path: string,
|
||||||
|
columns: string[],
|
||||||
|
rows: unknown[][]
|
||||||
|
) => invoke<void>("export_json", { path, columns, rows });
|
||||||
75
src/stores/app-store.ts
Normal file
75
src/stores/app-store.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { ConnectionConfig, Tab } from "@/types";
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
connections: ConnectionConfig[];
|
||||||
|
activeConnectionId: string | null;
|
||||||
|
currentDatabase: string | null;
|
||||||
|
connectedIds: Set<string>;
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
sidebarWidth: number;
|
||||||
|
pgVersion: string | null;
|
||||||
|
|
||||||
|
setConnections: (connections: ConnectionConfig[]) => void;
|
||||||
|
setActiveConnectionId: (id: string | null) => void;
|
||||||
|
setCurrentDatabase: (db: string | null) => void;
|
||||||
|
addConnectedId: (id: string) => void;
|
||||||
|
removeConnectedId: (id: string) => void;
|
||||||
|
setPgVersion: (version: string | null) => void;
|
||||||
|
|
||||||
|
addTab: (tab: Tab) => void;
|
||||||
|
closeTab: (id: string) => void;
|
||||||
|
setActiveTabId: (id: string | null) => void;
|
||||||
|
updateTab: (id: string, updates: Partial<Tab>) => void;
|
||||||
|
setSidebarWidth: (width: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
connections: [],
|
||||||
|
activeConnectionId: null,
|
||||||
|
currentDatabase: null,
|
||||||
|
connectedIds: new Set(),
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
sidebarWidth: 260,
|
||||||
|
pgVersion: null,
|
||||||
|
|
||||||
|
setConnections: (connections) => set({ connections }),
|
||||||
|
setActiveConnectionId: (id) => set({ activeConnectionId: id }),
|
||||||
|
setCurrentDatabase: (db) => set({ currentDatabase: db }),
|
||||||
|
addConnectedId: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
connectedIds: new Set([...state.connectedIds, id]),
|
||||||
|
})),
|
||||||
|
removeConnectedId: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.connectedIds);
|
||||||
|
next.delete(id);
|
||||||
|
return { connectedIds: next };
|
||||||
|
}),
|
||||||
|
setPgVersion: (version) => set({ pgVersion: version }),
|
||||||
|
|
||||||
|
addTab: (tab) =>
|
||||||
|
set((state) => ({
|
||||||
|
tabs: [...state.tabs, tab],
|
||||||
|
activeTabId: tab.id,
|
||||||
|
})),
|
||||||
|
closeTab: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const tabs = state.tabs.filter((t) => t.id !== id);
|
||||||
|
const activeTabId =
|
||||||
|
state.activeTabId === id
|
||||||
|
? tabs.length > 0
|
||||||
|
? tabs[tabs.length - 1].id
|
||||||
|
: null
|
||||||
|
: state.activeTabId;
|
||||||
|
return { tabs, activeTabId };
|
||||||
|
}),
|
||||||
|
setActiveTabId: (id) => set({ activeTabId: id }),
|
||||||
|
updateTab: (id, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
tabs: state.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)),
|
||||||
|
})),
|
||||||
|
setSidebarWidth: (width) => set({ sidebarWidth: width }),
|
||||||
|
}));
|
||||||
66
src/types/index.ts
Normal file
66
src/types/index.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export interface ConnectionConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
ssl_mode?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
columns: string[];
|
||||||
|
types: string[];
|
||||||
|
rows: unknown[][];
|
||||||
|
row_count: number;
|
||||||
|
execution_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedQueryResult extends QueryResult {
|
||||||
|
total_rows: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaObject {
|
||||||
|
name: string;
|
||||||
|
object_type: string;
|
||||||
|
schema: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnInfo {
|
||||||
|
name: string;
|
||||||
|
data_type: string;
|
||||||
|
is_nullable: boolean;
|
||||||
|
column_default: string | null;
|
||||||
|
ordinal_position: number;
|
||||||
|
character_maximum_length: number | null;
|
||||||
|
is_primary_key: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstraintInfo {
|
||||||
|
name: string;
|
||||||
|
constraint_type: string;
|
||||||
|
columns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexInfo {
|
||||||
|
name: string;
|
||||||
|
definition: string;
|
||||||
|
is_unique: boolean;
|
||||||
|
is_primary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabType = "query" | "table" | "structure";
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
id: string;
|
||||||
|
type: TabType;
|
||||||
|
title: string;
|
||||||
|
connectionId: string;
|
||||||
|
schema?: string;
|
||||||
|
table?: string;
|
||||||
|
sql?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user