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:
27
src/components/layout/Sidebar.tsx
Normal file
27
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/layout/StatusBar.tsx
Normal file
38
src/components/layout/StatusBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/layout/TabBar.tsx
Normal file
47
src/components/layout/TabBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/layout/Toolbar.tsx
Normal file
86
src/components/layout/Toolbar.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
398
src/components/schema/SchemaTree.tsx
Normal file
398
src/components/schema/SchemaTree.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user