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:
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