feat: add schema browser sidebar and app layout

Add SchemaTree with database/schema/category hierarchy, Sidebar with
search, Toolbar, TabBar, StatusBar, and full app layout with
resizable panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:06:42 +03:00
parent 734b84b525
commit d333732346
5 changed files with 596 additions and 0 deletions

View File

@@ -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 (
<div className="flex h-full flex-col bg-card">
<div className="p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search objects..."
className="h-7 pl-7 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<SchemaTree />
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex h-6 items-center justify-between border-t bg-card px-3 text-[11px] text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Circle
className={`h-2 w-2 ${isConnected ? "fill-green-500 text-green-500" : "fill-muted text-muted"}`}
/>
{activeConn ? activeConn.name : "No connection"}
</span>
{pgVersion && (
<span className="hidden sm:inline">{pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")}</span>
)}
</div>
<div className="flex items-center gap-3">
{rowCount != null && <span>{rowCount.toLocaleString()} rows</span>}
{executionTime != null && <span>{executionTime} ms</span>}
</div>
</div>
);
}

View File

@@ -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: <Code className="h-3 w-3" />,
table: <Table2 className="h-3 w-3" />,
structure: <Columns className="h-3 w-3" />,
};
return (
<div className="border-b bg-card">
<ScrollArea>
<div className="flex">
{tabs.map((tab) => (
<div
key={tab.id}
className={`group flex h-8 cursor-pointer items-center gap-1.5 border-r px-3 text-xs ${
activeTabId === tab.id
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTabId(tab.id)}
>
{iconMap[tab.type]}
<span className="max-w-[120px] truncate">{tab.title}</span>
<button
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -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<ConnectionConfig | null>(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 (
<>
<div className="flex h-10 items-center gap-2 border-b px-3 bg-card">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5"
onClick={() => setListOpen(true)}
>
<Database className="h-3.5 w-3.5" />
Connections
</Button>
<Separator orientation="vertical" className="h-5" />
<ConnectionSelector />
<Separator orientation="vertical" className="h-5" />
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5"
onClick={handleNewQuery}
disabled={!activeConnectionId}
>
<Plus className="h-3.5 w-3.5" />
New Query
</Button>
<div className="flex-1" />
<span className="text-xs font-semibold text-muted-foreground tracking-wide">
TUSK
</span>
</div>
<ConnectionList
open={listOpen}
onOpenChange={setListOpen}
onEdit={(conn) => {
setEditingConn(conn);
setDialogOpen(true);
}}
onNew={() => {
setEditingConn(null);
setDialogOpen(true);
}}
/>
<ConnectionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
connection={editingConn}
/>
</>
);
}

View File

@@ -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 (
<div className="p-4 text-sm text-muted-foreground">
Connect to a database to browse schema.
</div>
);
}
if (!databases || databases.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground">
No databases found.
</div>
);
}
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 (
<div className="space-y-0.5 p-2">
{databases.map((db) => (
<DatabaseNode
key={db}
name={db}
isActive={db === currentDatabase}
connectionId={activeConnectionId}
onSwitch={() => 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);
}}
/>
))}
</div>
);
}
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 (
<div>
<div
className={`flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium ${
isActive ? "text-primary" : "text-muted-foreground"
}`}
onClick={handleClick}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<HardDrive
className={`h-3.5 w-3.5 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="truncate">{name}</span>
{isActive && (
<span className="ml-auto text-[10px] text-primary">active</span>
)}
</div>
{expanded && isActive && (
<div className="ml-4">
<SchemasForCurrentDb
connectionId={connectionId}
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
/>
</div>
)}
{expanded && !isActive && (
<div className="ml-6 py-1 text-xs text-muted-foreground">
{isSwitching ? "Switching..." : "Click to switch to this database"}
</div>
)}
</div>
);
}
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 (
<div className="py-1 text-xs text-muted-foreground">No schemas found.</div>
);
}
return (
<>
{schemas.map((schema) => (
<SchemaNode
key={schema}
schema={schema}
connectionId={connectionId}
onOpenTable={(table) => 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 (
<div>
<div
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none font-medium"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
{expanded ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span>{schema}</span>
</div>
{expanded && (
<div className="ml-4">
<CategoryNode
label="Tables"
connectionId={connectionId}
schema={schema}
category="tables"
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
/>
<CategoryNode
label="Views"
connectionId={connectionId}
schema={schema}
category="views"
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
/>
<CategoryNode
label="Functions"
connectionId={connectionId}
schema={schema}
category="functions"
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
/>
<CategoryNode
label="Sequences"
connectionId={connectionId}
schema={schema}
category="sequences"
onOpenTable={onOpenTable}
onViewStructure={onViewStructure}
/>
</div>
)}
</div>
);
}
const categoryIcons = {
tables: <Table2 className="h-3.5 w-3.5 text-blue-400" />,
views: <Eye className="h-3.5 w-3.5 text-green-400" />,
functions: <FunctionSquare className="h-3.5 w-3.5 text-purple-400" />,
sequences: <Hash className="h-3.5 w-3.5 text-orange-400" />,
};
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 (
<div>
<div
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
{icon}
<span className="truncate">
{label}
{items ? ` (${items.length})` : ""}
</span>
</div>
{expanded && (
<div className="ml-4">
{items?.map((item) => {
const isTableOrView = category === "tables" || category === "views";
if (isTableOrView) {
return (
<ContextMenu key={item.name}>
<ContextMenuTrigger>
<div
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
onDoubleClick={() => onOpenTable(item.name)}
>
<span className="w-3.5 shrink-0" />
{icon}
<span className="truncate">{item.name}</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onOpenTable(item.name)}>
Open Data
</ContextMenuItem>
<ContextMenuItem
onClick={() => onViewStructure(item.name)}
>
View Structure
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div
key={item.name}
className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm hover:bg-accent cursor-pointer select-none"
>
<span className="w-3.5 shrink-0" />
{icon}
<span className="truncate">{item.name}</span>
</div>
);
})}
</div>
)}
</div>
);
}