diff --git a/src/components/connections/ConnectionDialog.tsx b/src/components/connections/ConnectionDialog.tsx new file mode 100644 index 0000000..70f1f9c --- /dev/null +++ b/src/components/connections/ConnectionDialog.tsx @@ -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(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 ( + + + + + {connection ? "Edit Connection" : "New Connection"} + + + +
+
+ + update("name", e.target.value)} + placeholder="My Database" + /> +
+
+ + update("host", e.target.value)} + /> +
+
+ + update("port", parseInt(e.target.value) || 5432)} + /> +
+
+ + update("user", e.target.value)} + /> +
+
+ + update("password", e.target.value)} + /> +
+
+ + update("database", e.target.value)} + /> +
+
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/connections/ConnectionList.tsx b/src/components/connections/ConnectionList.tsx new file mode 100644 index 0000000..544d229 --- /dev/null +++ b/src/components/connections/ConnectionList.tsx @@ -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 ( + + + + + Connections + + + +
+
+ {connections?.map((conn) => { + const isConnected = connectedIds.has(conn.id); + const isActive = activeConnectionId === conn.id; + + return ( +
+ +
+
+ {conn.name} +
+
+ {conn.host}:{conn.port}/{conn.database} +
+
+
+ {isConnected ? ( + + ) : ( + + )} + + +
+
+ ); + })} + {(!connections || connections.length === 0) && ( +
+ No saved connections. +
+ Click "New" to add one. +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/connections/ConnectionSelector.tsx b/src/components/connections/ConnectionSelector.tsx new file mode 100644 index 0000000..2eb6268 --- /dev/null +++ b/src/components/connections/ConnectionSelector.tsx @@ -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 ( +
Not connected
+ ); + } + + return ( + + ); +} diff --git a/src/hooks/use-connections.ts b/src/hooks/use-connections.ts new file mode 100644 index 0000000..accd40f --- /dev/null +++ b/src/hooks/use-connections.ts @@ -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); + } + }, + }); +} diff --git a/src/hooks/use-query-execution.ts b/src/hooks/use-query-execution.ts new file mode 100644 index 0000000..a98388d --- /dev/null +++ b/src/hooks/use-query-execution.ts @@ -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), + }); +} diff --git a/src/hooks/use-schema.ts b/src/hooks/use-schema.ts new file mode 100644 index 0000000..f3bb3a2 --- /dev/null +++ b/src/hooks/use-schema.ts @@ -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, + }); +} diff --git a/src/hooks/use-table-data.ts b/src/hooks/use-table-data.ts new file mode 100644 index 0000000..4c3c5bb --- /dev/null +++ b/src/hooks/use-table-data.ts @@ -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, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts new file mode 100644 index 0000000..e0f068c --- /dev/null +++ b/src/lib/tauri.ts @@ -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("get_connections"); + +export const saveConnection = (config: ConnectionConfig) => + invoke("save_connection", { config }); + +export const deleteConnection = (id: string) => + invoke("delete_connection", { id }); + +export const testConnection = (config: ConnectionConfig) => + invoke("test_connection", { config }); + +export const connectDb = (config: ConnectionConfig) => + invoke("connect", { config }); + +export const disconnectDb = (id: string) => + invoke("disconnect", { id }); + +export const switchDatabase = (config: ConnectionConfig, database: string) => + invoke("switch_database", { config, database }); + +// Queries +export const executeQuery = (connectionId: string, sql: string) => + invoke("execute_query", { connectionId, sql }); + +// Schema +export const listDatabases = (connectionId: string) => + invoke("list_databases", { connectionId }); + +export const listSchemas = (connectionId: string) => + invoke("list_schemas", { connectionId }); + +export const listTables = (connectionId: string, schema: string) => + invoke("list_tables", { connectionId, schema }); + +export const listViews = (connectionId: string, schema: string) => + invoke("list_views", { connectionId, schema }); + +export const listFunctions = (connectionId: string, schema: string) => + invoke("list_functions", { connectionId, schema }); + +export const listIndexes = (connectionId: string, schema: string) => + invoke("list_indexes", { connectionId, schema }); + +export const listSequences = (connectionId: string, schema: string) => + invoke("list_sequences", { connectionId, schema }); + +export const getTableColumns = ( + connectionId: string, + schema: string, + table: string +) => invoke("get_table_columns", { connectionId, schema, table }); + +export const getTableConstraints = ( + connectionId: string, + schema: string, + table: string +) => + invoke("get_table_constraints", { + connectionId, + schema, + table, + }); + +export const getTableIndexes = ( + connectionId: string, + schema: string, + table: string +) => invoke("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("get_table_data", params); + +export const updateRow = (params: { + connectionId: string; + schema: string; + table: string; + pkColumns: string[]; + pkValues: unknown[]; + column: string; + value: unknown; +}) => invoke("update_row", params); + +export const insertRow = (params: { + connectionId: string; + schema: string; + table: string; + columns: string[]; + values: unknown[]; +}) => invoke("insert_row", params); + +export const deleteRows = (params: { + connectionId: string; + schema: string; + table: string; + pkColumns: string[]; + pkValuesList: unknown[][]; +}) => invoke("delete_rows", params); + +// Export +export const exportCsv = ( + path: string, + columns: string[], + rows: unknown[][] +) => invoke("export_csv", { path, columns, rows }); + +export const exportJson = ( + path: string, + columns: string[], + rows: unknown[][] +) => invoke("export_json", { path, columns, rows }); diff --git a/src/stores/app-store.ts b/src/stores/app-store.ts new file mode 100644 index 0000000..0199530 --- /dev/null +++ b/src/stores/app-store.ts @@ -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; + 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) => void; + setSidebarWidth: (width: number) => void; +} + +export const useAppStore = create((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 }), +})); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..504240c --- /dev/null +++ b/src/types/index.ts @@ -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; +}