diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..4d18d9c --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { SchemaTree } from "@/components/schema/SchemaTree"; +import { Search } from "lucide-react"; + +export function Sidebar() { + const [search, setSearch] = useState(""); + + return ( +
+
+
+ + setSearch(e.target.value)} + /> +
+
+
+ +
+
+ ); +} diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx new file mode 100644 index 0000000..343750c --- /dev/null +++ b/src/components/layout/StatusBar.tsx @@ -0,0 +1,38 @@ +import { useAppStore } from "@/stores/app-store"; +import { useConnections } from "@/hooks/use-connections"; +import { Circle } from "lucide-react"; + +interface Props { + rowCount?: number | null; + executionTime?: number | null; +} + +export function StatusBar({ rowCount, executionTime }: Props) { + const { activeConnectionId, connectedIds, pgVersion } = useAppStore(); + const { data: connections } = useConnections(); + + const activeConn = connections?.find((c) => c.id === activeConnectionId); + const isConnected = activeConnectionId + ? connectedIds.has(activeConnectionId) + : false; + + return ( +
+
+ + + {activeConn ? activeConn.name : "No connection"} + + {pgVersion && ( + {pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")} + )} +
+
+ {rowCount != null && {rowCount.toLocaleString()} rows} + {executionTime != null && {executionTime} ms} +
+
+ ); +} diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx new file mode 100644 index 0000000..535d318 --- /dev/null +++ b/src/components/layout/TabBar.tsx @@ -0,0 +1,47 @@ +import { useAppStore } from "@/stores/app-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { X, Table2, Code, Columns } from "lucide-react"; + +export function TabBar() { + const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore(); + + if (tabs.length === 0) return null; + + const iconMap = { + query: , + table: , + structure: , + }; + + return ( +
+ +
+ {tabs.map((tab) => ( +
setActiveTabId(tab.id)} + > + {iconMap[tab.type]} + {tab.title} + +
+ ))} +
+
+
+ ); +} diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx new file mode 100644 index 0000000..a4b1ea3 --- /dev/null +++ b/src/components/layout/Toolbar.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ConnectionSelector } from "@/components/connections/ConnectionSelector"; +import { ConnectionList } from "@/components/connections/ConnectionList"; +import { ConnectionDialog } from "@/components/connections/ConnectionDialog"; +import { useAppStore } from "@/stores/app-store"; +import { Database, Plus } from "lucide-react"; +import type { ConnectionConfig, Tab } from "@/types"; + +export function Toolbar() { + const [listOpen, setListOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingConn, setEditingConn] = useState(null); + const { activeConnectionId, addTab } = useAppStore(); + + const handleNewQuery = () => { + if (!activeConnectionId) return; + const tab: Tab = { + id: crypto.randomUUID(), + type: "query", + title: "New Query", + connectionId: activeConnectionId, + sql: "", + }; + addTab(tab); + }; + + return ( + <> +
+ + + + + + + + + + +
+ + + TUSK + +
+ + { + setEditingConn(conn); + setDialogOpen(true); + }} + onNew={() => { + setEditingConn(null); + setDialogOpen(true); + }} + /> + + + + ); +} diff --git a/src/components/schema/SchemaTree.tsx b/src/components/schema/SchemaTree.tsx new file mode 100644 index 0000000..ac91d69 --- /dev/null +++ b/src/components/schema/SchemaTree.tsx @@ -0,0 +1,398 @@ +import { useState } from "react"; +import { + useDatabases, + useSwitchDatabase, + useSchemas, + useTables, + useViews, + useFunctions, + useSequences, +} from "@/hooks/use-schema"; +import { useConnections } from "@/hooks/use-connections"; +import { useAppStore } from "@/stores/app-store"; +import { toast } from "sonner"; +import { + Database, + Table2, + Eye, + FunctionSquare, + Hash, + FolderOpen, + ChevronRight, + ChevronDown, + HardDrive, +} from "lucide-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import type { Tab } from "@/types"; + +export function SchemaTree() { + const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } = + useAppStore(); + const { data: databases } = useDatabases(activeConnectionId); + const { data: connections } = useConnections(); + const switchDbMutation = useSwitchDatabase(); + + if (!activeConnectionId) { + return ( +
+ Connect to a database to browse schema. +
+ ); + } + + if (!databases || databases.length === 0) { + return ( +
+ No databases found. +
+ ); + } + + const activeConfig = connections?.find((c) => c.id === activeConnectionId); + + const handleSwitchDb = (dbName: string) => { + if (!activeConfig || dbName === currentDatabase) return; + switchDbMutation.mutate( + { config: activeConfig, database: dbName }, + { + onSuccess: () => { + setCurrentDatabase(dbName); + toast.success(`Switched to database: ${dbName}`); + }, + onError: (err) => { + toast.error("Failed to switch database", { + description: String(err), + }); + }, + } + ); + }; + + return ( +
+ {databases.map((db) => ( + handleSwitchDb(db)} + isSwitching={switchDbMutation.isPending} + onOpenTable={(schema, table) => { + const tab: Tab = { + id: crypto.randomUUID(), + type: "table", + title: table, + connectionId: activeConnectionId, + schema, + table, + }; + addTab(tab); + }} + onViewStructure={(schema, table) => { + const tab: Tab = { + id: crypto.randomUUID(), + type: "structure", + title: `${table} (structure)`, + connectionId: activeConnectionId, + schema, + table, + }; + addTab(tab); + }} + /> + ))} +
+ ); +} + +function DatabaseNode({ + name, + isActive, + connectionId, + onSwitch, + isSwitching, + onOpenTable, + onViewStructure, +}: { + name: string; + isActive: boolean; + connectionId: string; + onSwitch: () => void; + isSwitching: boolean; + onOpenTable: (schema: string, table: string) => void; + onViewStructure: (schema: string, table: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + + const handleClick = () => { + if (!isActive) { + onSwitch(); + } + setExpanded(!expanded); + }; + + return ( +
+
+ {expanded ? ( + + ) : ( + + )} + + {name} + {isActive && ( + active + )} +
+ {expanded && isActive && ( +
+ +
+ )} + {expanded && !isActive && ( +
+ {isSwitching ? "Switching..." : "Click to switch to this database"} +
+ )} +
+ ); +} + +function SchemasForCurrentDb({ + connectionId, + onOpenTable, + onViewStructure, +}: { + connectionId: string; + onOpenTable: (schema: string, table: string) => void; + onViewStructure: (schema: string, table: string) => void; +}) { + const { data: schemas } = useSchemas(connectionId); + + if (!schemas || schemas.length === 0) { + return ( +
No schemas found.
+ ); + } + + return ( + <> + {schemas.map((schema) => ( + onOpenTable(schema, table)} + onViewStructure={(table) => onViewStructure(schema, table)} + /> + ))} + + ); +} + +function SchemaNode({ + schema, + connectionId, + onOpenTable, + onViewStructure, +}: { + schema: string; + connectionId: string; + onOpenTable: (table: string) => void; + onViewStructure: (table: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + {expanded ? ( + + ) : ( + + )} + {schema} +
+ {expanded && ( +
+ + + + +
+ )} +
+ ); +} + +const categoryIcons = { + tables: , + views: , + functions: , + sequences: , +}; + +function CategoryNode({ + label, + connectionId, + schema, + category, + onOpenTable, + onViewStructure, +}: { + label: string; + connectionId: string; + schema: string; + category: "tables" | "views" | "functions" | "sequences"; + onOpenTable: (table: string) => void; + onViewStructure: (table: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + + const tablesQuery = useTables( + expanded && category === "tables" ? connectionId : null, + schema + ); + const viewsQuery = useViews( + expanded && category === "views" ? connectionId : null, + schema + ); + const functionsQuery = useFunctions( + expanded && category === "functions" ? connectionId : null, + schema + ); + const sequencesQuery = useSequences( + expanded && category === "sequences" ? connectionId : null, + schema + ); + + const items = + category === "tables" + ? tablesQuery.data + : category === "views" + ? viewsQuery.data + : category === "functions" + ? functionsQuery.data + : sequencesQuery.data; + + const icon = categoryIcons[category]; + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + {icon} + + {label} + {items ? ` (${items.length})` : ""} + +
+ {expanded && ( +
+ {items?.map((item) => { + const isTableOrView = category === "tables" || category === "views"; + + if (isTableOrView) { + return ( + + +
onOpenTable(item.name)} + > + + {icon} + {item.name} +
+
+ + onOpenTable(item.name)}> + Open Data + + onViewStructure(item.name)} + > + View Structure + + +
+ ); + } + + return ( +
+ + {icon} + {item.name} +
+ ); + })} +
+ )} +
+ ); +}