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