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>
);
}

View 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);
}
},
});
}

View 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
View 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,
});
}

View 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
View 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
View 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
View 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;
}