Release / publish (, ubuntu-22.04) (push) Failing after 14m39s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Release / publish (, windows-latest) (push) Canceled after 0s
* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1) * feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2) * feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3) * feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1) * feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2) * feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3) * feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4) * feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5) * feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1) * feat(store): roles slice + openFormTab table/role dedup (Task 3.2) * feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1) * feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2) * feat(ui): wire roles into object registry + explorer (Task 4.3) * feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4) * chore: bump version to 0.7.7 (Task 5.1) * docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2) * test: v0.7.7 manual smoke checklist (Task 5.3) * chore: sync Cargo.lock to v0.7.7 * style(ui): match TableForm visual layout to shared object-form rows (remove stray padding) * feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls * style(ui): table-creator columns grid — bordered cells like the data table view * feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row * feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints * style(ui): table creator — constraints column sized to content (no clip), overscroll containment * style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2 * feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema * style(ui): table creator — columns grid fills container width, scrolls only when too narrow * feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family * feat(ui): FK panel — animate in/out from the right, flush create-table-style rows * fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid * feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs * fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs * style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker * style(ui): FK panel — vertical divider between local/ref columns * style(ui): FK panel — section title padding py-2 * style(ui): FK panel — remove row margins + select/text padding in column picker * style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells * style(ui): FK panel — cell padding + row borders on column picker (user polish) * feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class * style(ui): hide FK icon on PK columns; fix grid header/row column alignment * feat(ui): SQL preview in table creator uses read-only Monaco editor * feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions * style(ui): FK relation row — label, icon, table on one line * style(ui): FormRow selects transparent like FK panel; test matches FK relation label * style(ui): form-row focus outline amber-400/20 (subtle) * style(ui): SQL view — remove px-4 py-3 wrapper padding * feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card * fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64 * fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed) * feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more * feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all * chore(test): drop unused fireEvent import in RoleDetail test * style(ui): hide chevron when section has 5 or fewer entries * docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins) * docs(readme): collapse Recent changes and Key features into details blocks * docs(readme): move hero caption under image; align download + comparison table columns * docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible * docs(readme): per-subsection collapsibles with meaningful summary labels
632 lines
28 KiB
TypeScript
632 lines
28 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
|
|
import { ChevronRight, Plus, Search, X, RefreshCw } from "lucide-react";
|
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
|
import { SelectDropdown } from "../ui/SelectDropdown";
|
|
import { DependencyDialog } from "./DependencyDialog";
|
|
import { ObjectContextMenu } from "./objects/ObjectContextMenu";
|
|
import {
|
|
ObjectDetail,
|
|
OBJECT_ICONS,
|
|
TYPE_LABELS,
|
|
SINGULAR_LABELS,
|
|
type AnyObject,
|
|
} from "./objects/ObjectDetail";
|
|
import { initialCrudParams, type ObjectKind } from "../../lib/objectCrud";
|
|
import * as cmd from "../../lib/commands";
|
|
import type { ObjectType, DependencyInfo } from "../../lib/types";
|
|
|
|
|
|
|
|
interface ObjectExplorerPageProps {
|
|
connectionId: string;
|
|
sidebarMode?: boolean;
|
|
}
|
|
|
|
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
|
(t) => ({ value: t, label: TYPE_LABELS[t] }),
|
|
);
|
|
|
|
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
|
function emptyPlural(type: ObjectType): string {
|
|
const singular = SINGULAR_LABELS[type];
|
|
const irregulars: Record<string, string> = {
|
|
index: "indexes",
|
|
constraint: "constraints",
|
|
};
|
|
return irregulars[singular] ?? `${singular}s`;
|
|
}
|
|
|
|
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
|
|
function itemKey(item: AnyObject): string {
|
|
const name = (item as any).name as string;
|
|
if ("argument_types" in item && Array.isArray(item.argument_types)) {
|
|
return `${name}(${item.argument_types.join(",")})`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/** Display name for the tree list. Functions show their argument signature. */
|
|
function itemLabel(item: AnyObject): string {
|
|
const name = (item as any).name as string;
|
|
if (
|
|
"argument_types" in item &&
|
|
Array.isArray(item.argument_types) &&
|
|
item.argument_types.length > 0
|
|
) {
|
|
return `${name}(${item.argument_types.join(", ")})`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function schemaOf(item: AnyObject): string {
|
|
// Roles are cluster-scoped and carry no schema — default to "".
|
|
return (item as { schema?: string }).schema ?? "";
|
|
}
|
|
|
|
function objectName(item: AnyObject): string {
|
|
return item.name;
|
|
}
|
|
|
|
function typeToDdlType(type: ObjectType): string {
|
|
const map: Record<ObjectType, string> = {
|
|
functions: "function",
|
|
procedures: "procedure",
|
|
triggers: "trigger",
|
|
sequences: "sequence",
|
|
enums: "enum",
|
|
extensions: "extension",
|
|
indexes: "index",
|
|
constraints: "constraint",
|
|
roles: "role",
|
|
};
|
|
return map[type];
|
|
}
|
|
|
|
export function ObjectExplorerPage({
|
|
connectionId,
|
|
sidebarMode = false,
|
|
}: ObjectExplorerPageProps) {
|
|
const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType);
|
|
const [type, setType] = useState<ObjectType>(selectedObjectType ?? "functions");
|
|
const [panelWidth, setPanelWidth] = useState(280);
|
|
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
|
|
null,
|
|
);
|
|
|
|
const onPanelResizeStart = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
panelResizeRef.current = { startX: e.clientX, startW: panelWidth };
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!panelResizeRef.current) return;
|
|
const delta = ev.clientX - panelResizeRef.current.startX;
|
|
const next = Math.max(
|
|
180,
|
|
Math.min(500, panelResizeRef.current.startW + delta),
|
|
);
|
|
setPanelWidth(next);
|
|
};
|
|
const onUp = () => {
|
|
panelResizeRef.current = null;
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
},
|
|
[panelWidth],
|
|
);
|
|
|
|
const databases = useDbViewerStore((s) => s.databases);
|
|
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
|
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
|
const schemas = useDbViewerStore((s) => s.schemas);
|
|
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
|
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
|
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
|
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
|
|
const refreshTree = useDbViewerStore((s) => s.refreshTree);
|
|
|
|
// Use store for persistence, but allow re-fetching when schema changes
|
|
const [items, setItems] = useState<AnyObject[] | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedItem, setSelectedItem] = useState<AnyObject | null>(null);
|
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
|
const [depOpen, setDepOpen] = useState(false);
|
|
const [depDeps, setDepDeps] = useState<DependencyInfo[]>([]);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Track last-fetched-schema so we know when to re-fetch
|
|
const lastSchemaRef = useRef<string | undefined>(undefined);
|
|
// Track the last committed ddl change so a commit triggers exactly one
|
|
// refetch (no duplicate refetches across re-renders).
|
|
const lastCommittedDdlRef = useRef<string>("");
|
|
|
|
// Fetch on mount and when schema changes
|
|
const fetch = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
let result: AnyObject[];
|
|
if (type === "roles") {
|
|
// Roles are cluster-scoped — no schema filter.
|
|
result = await cmd.getRoles(connectionId);
|
|
} else if (type === "extensions") {
|
|
result = await cmd.getExtensions(connectionId);
|
|
} else if (type === "functions") {
|
|
result = (await cmd.getFunctions(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
)).filter((f) => f.kind === "f");
|
|
} else if (type === "procedures") {
|
|
result = (await cmd.getFunctions(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
)).filter((f) => f.kind === "p");
|
|
} else if (type === "triggers") {
|
|
result = await cmd.getTriggers(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
);
|
|
} else if (type === "sequences") {
|
|
result = await cmd.getSequences(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
);
|
|
} else if (type === "enums") {
|
|
result = await cmd.getEnums(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
);
|
|
} else if (type === "indexes") {
|
|
result = await cmd.getIndexes(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
);
|
|
} else if (type === "constraints") {
|
|
result = await cmd.getConstraints(
|
|
connectionId,
|
|
currentSchema ?? undefined,
|
|
);
|
|
} else {
|
|
result = [];
|
|
}
|
|
setItems(result);
|
|
setSelectedItem(null);
|
|
lastSchemaRef.current = currentSchema ?? undefined;
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
setItems(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [type, connectionId, currentSchema]);
|
|
|
|
useEffect(() => {
|
|
// Only re-fetch if schema actually changed (or first load). Roles are
|
|
// cluster-scoped — skip the refetch when the schema changes (the mount
|
|
// fetch still runs via lastSchemaRef === undefined).
|
|
const schema = currentSchema ?? undefined;
|
|
const schemaChanged = lastSchemaRef.current !== schema;
|
|
const skipForRoles =
|
|
schemaChanged && type === "roles" && lastSchemaRef.current !== undefined;
|
|
if (schemaChanged && !skipForRoles) {
|
|
fetch();
|
|
}
|
|
}, [currentSchema, fetch]);
|
|
|
|
// After a commit containing ddl items succeeds, refetch the current
|
|
// object-type list + refresh the schema tree so newly created/dropped
|
|
// objects show up without a manual refresh.
|
|
useEffect(() => {
|
|
const committedDdl = changesQueue.filter(
|
|
(c) => c.type === "ddl" && c.status === "committed",
|
|
);
|
|
const lastCommitted = committedDdl[committedDdl.length - 1];
|
|
if (lastCommitted && lastCommitted.id !== lastCommittedDdlRef.current) {
|
|
lastCommittedDdlRef.current = lastCommitted.id;
|
|
fetch();
|
|
void refreshTree(connectionId, currentSchema ?? undefined);
|
|
}
|
|
}, [changesQueue, connectionId, currentSchema, fetch, refreshTree]);
|
|
|
|
// Search toggle handling
|
|
useEffect(() => {
|
|
if (searchOpen && searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, [searchOpen]);
|
|
|
|
const handleSearchBlur = useCallback(() => {
|
|
setTimeout(() => {
|
|
if (!searchQuery.trim()) {
|
|
setSearchOpen(false);
|
|
}
|
|
}, 150);
|
|
}, [searchQuery]);
|
|
|
|
const toggleSearch = useCallback(() => {
|
|
setSearchOpen((prev) => {
|
|
const next = !prev;
|
|
if (!next) setSearchQuery("");
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const q = searchQuery.toLowerCase().trim();
|
|
const filtered = useMemo(() => {
|
|
if (!items) return [];
|
|
if (!q) return items;
|
|
return items.filter((item) => {
|
|
const label = itemLabel(item).toLowerCase();
|
|
return label.includes(q);
|
|
});
|
|
}, [items, q]);
|
|
|
|
const icon = OBJECT_ICONS[type];
|
|
const label = TYPE_LABELS[type];
|
|
const singular = SINGULAR_LABELS[type];
|
|
|
|
// Header create button: open an objectForm tab prefilled with create state for the current type.
|
|
const openCreate = useCallback(() => {
|
|
const kind = typeToDdlType(type) as ObjectKind;
|
|
const schema = currentSchema ?? "public";
|
|
const singular = SINGULAR_LABELS[type];
|
|
useDbViewerStore.getState().openFormTab({
|
|
kind,
|
|
schema,
|
|
name: "",
|
|
title: `Create ${singular}`,
|
|
description: `Create ${singular}`,
|
|
mode: "create",
|
|
params: initialCrudParams(kind, { schema, name: "" }, "create"),
|
|
});
|
|
}, [type, currentSchema]);
|
|
|
|
// Switching object type: reset selection/search, clear the stale list so
|
|
// the loading state renders (no flash of the previous type's objects), and
|
|
// reset the last-fetched-schema marker so the fetch effect re-runs.
|
|
const handleTypeChange = useCallback((next: ObjectType) => {
|
|
setType(next);
|
|
setSearchQuery("");
|
|
setSelectedItem(null);
|
|
setOpenKey(null);
|
|
setItems(null);
|
|
setLoading(true);
|
|
lastSchemaRef.current = undefined;
|
|
}, []);
|
|
|
|
// Consume any object-type preselection from the Cmd+K palette.
|
|
useEffect(() => {
|
|
if (selectedObjectType && selectedObjectType !== type) {
|
|
handleTypeChange(selectedObjectType);
|
|
}
|
|
if (selectedObjectType) {
|
|
useDbViewerStore.getState().setSelectedObjectType(null);
|
|
}
|
|
}, [selectedObjectType, type, handleTypeChange]);
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
{/* Left panel: toolbar + object list */}
|
|
<div
|
|
className={
|
|
sidebarMode
|
|
? "flex flex-col flex-1 min-h-0 overflow-hidden"
|
|
: "border-r border-border flex flex-col shrink-0"
|
|
}
|
|
style={sidebarMode ? undefined : { width: panelWidth }}
|
|
>
|
|
<div className="p-3 border-b border-border space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<SelectDropdown
|
|
value={type}
|
|
onChange={(v) => handleTypeChange(v as ObjectType)}
|
|
options={OBJECT_TYPE_OPTIONS}
|
|
variant="ghost"
|
|
aria-label="Object type"
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
aria-label={`Create ${singular}`}
|
|
onClick={openCreate}
|
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
<button
|
|
aria-label="Refresh"
|
|
onClick={fetch}
|
|
disabled={loading}
|
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
|
|
>
|
|
<RefreshCw
|
|
size={14}
|
|
className={loading ? "animate-spin" : ""}
|
|
/>
|
|
</button>
|
|
<button
|
|
aria-label={`Search ${label}`}
|
|
onClick={toggleSearch}
|
|
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
|
|
>
|
|
<Search size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<div
|
|
ref={searchContainerRef}
|
|
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
|
|
>
|
|
<div className="relative flex items-center">
|
|
<Search
|
|
size={12}
|
|
className="absolute left-2.5 text-text-muted pointer-events-none"
|
|
/>
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onBlur={handleSearchBlur}
|
|
placeholder={`Filter ${label.toLowerCase()}…`}
|
|
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery("")}
|
|
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Database/Schema dropdowns */}
|
|
{(databases.length > 1 || schemas.length > 1) && (
|
|
<div className="flex items-center gap-2">
|
|
{databases.length > 1 && (
|
|
<SelectDropdown
|
|
value={currentDatabase ?? ""}
|
|
onChange={(v) =>
|
|
setCurrentDatabase(v || null)
|
|
}
|
|
options={databases.map((d) => ({
|
|
value: d,
|
|
label: d,
|
|
}))}
|
|
placeholder="Select database"
|
|
aria-label="Select database"
|
|
variant="ghost"
|
|
/>
|
|
)}
|
|
{databases.length > 1 && schemas.length > 1 && (
|
|
<span className="text-border">|</span>
|
|
)}
|
|
{schemas.length > 1 && (
|
|
<SelectDropdown
|
|
value={currentSchema ?? ""}
|
|
onChange={(v) =>
|
|
setCurrentSchema(v || null)
|
|
}
|
|
options={schemas.map((s) => ({
|
|
value: s,
|
|
label: s,
|
|
}))}
|
|
placeholder="Select schema"
|
|
aria-label="Select schema"
|
|
variant="ghost"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Object list */}
|
|
<div
|
|
className="flex-1 overflow-y-auto"
|
|
style={{ overscrollBehavior: "none" }}
|
|
>
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
|
<RefreshCw
|
|
size={14}
|
|
className="animate-spin mr-2"
|
|
/>
|
|
Loading {label.toLowerCase()}...
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="px-3 py-2 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && filtered.length === 0 && (
|
|
<div className="px-3 py-2 text-sm text-text-muted">
|
|
{searchQuery
|
|
? `No ${emptyPlural(type)} matching "${searchQuery}"`
|
|
: `No ${emptyPlural(type)} found`}
|
|
</div>
|
|
)}
|
|
|
|
{!loading &&
|
|
filtered.map((item) => {
|
|
const name = itemLabel(item);
|
|
const key = itemKey(item);
|
|
const isSelected =
|
|
selectedItem !== null &&
|
|
itemKey(selectedItem) === itemKey(item);
|
|
|
|
const handleCopyDdl = async () => {
|
|
setOpenKey(null);
|
|
const ddl = await cmd.getObjectDdl(
|
|
connectionId,
|
|
schemaOf(item),
|
|
typeToDdlType(type),
|
|
objectName(item),
|
|
);
|
|
try {
|
|
await navigator.clipboard?.writeText(ddl);
|
|
} catch {
|
|
// Ignore clipboard errors.
|
|
}
|
|
};
|
|
|
|
const handleViewDependencies = async () => {
|
|
setOpenKey(null);
|
|
const deps = await cmd.getObjectDependencies(
|
|
connectionId,
|
|
schemaOf(item),
|
|
typeToDdlType(type),
|
|
objectName(item),
|
|
);
|
|
setDepDeps(deps);
|
|
setDepOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
onClick={() =>
|
|
sidebarMode
|
|
? openObjectTab(
|
|
type,
|
|
schemaOf(item),
|
|
item.name,
|
|
item,
|
|
)
|
|
: setSelectedItem(item)
|
|
}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
setOpenKey(key);
|
|
}}
|
|
className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${
|
|
isSelected
|
|
? "bg-accent/10 text-accent"
|
|
: "text-text hover:bg-surface-raised"
|
|
}`}
|
|
>
|
|
{icon}
|
|
<span className="flex-1 text-sm truncate">
|
|
{name}
|
|
</span>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="relative shrink-0"
|
|
>
|
|
<ObjectContextMenu
|
|
connectionId={connectionId}
|
|
objectType={
|
|
typeToDdlType(type) as ObjectKind
|
|
}
|
|
item={{
|
|
...item,
|
|
schema: schemaOf(item),
|
|
}}
|
|
onRefresh={fetch}
|
|
open={openKey === key}
|
|
onOpenChange={(o) =>
|
|
setOpenKey(o ? key : null)
|
|
}
|
|
extraItems={[
|
|
{
|
|
id: "copy-ddl",
|
|
label: "Copy DDL",
|
|
onClick: handleCopyDdl,
|
|
},
|
|
{
|
|
id: "dependencies",
|
|
label: "Dependencies",
|
|
onClick:
|
|
handleViewDependencies,
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
<ChevronRight
|
|
size={14}
|
|
className={`text-text-muted shrink-0 transition-transform ${
|
|
isSelected ? "rotate-90" : ""
|
|
}`}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{!sidebarMode && (
|
|
<>
|
|
{/* Panel resize handle */}
|
|
<div
|
|
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
|
onMouseDown={onPanelResizeStart}
|
|
onDoubleClick={() => setPanelWidth(280)}
|
|
/>
|
|
|
|
{/* Right panel: detail view */}
|
|
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-y-auto overflow-x-hidden">
|
|
{selectedItem ? (
|
|
<>
|
|
{/* Header */}
|
|
<div className="px-4 py-2 flex flex-row items-center justify-between border-b border-border">
|
|
<h2 className="text-lg font-semibold text-text font-mono">
|
|
{itemLabel(selectedItem)}
|
|
</h2>
|
|
<p className="text-xs text-text-muted capitalize">
|
|
{singular}
|
|
{"schema" in selectedItem
|
|
? ` · ${(selectedItem as any).schema}`
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Detail content */}
|
|
<div style={{ overflowX: "auto", width: "100%" }}>
|
|
<ObjectDetail
|
|
connectionId={connectionId}
|
|
type={type}
|
|
item={selectedItem}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-text-muted">
|
|
<div className="text-center space-y-2">
|
|
<div className="flex justify-center">
|
|
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}
|
|
</div>
|
|
<p className="text-sm">
|
|
Select a {singular} to view details
|
|
</p>
|
|
<p className="text-xs text-text-subtle">
|
|
{filtered.length} {label.toLowerCase()}{" "}
|
|
available
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<DependencyDialog
|
|
open={depOpen}
|
|
deps={depDeps}
|
|
onProceed={() => setDepOpen(false)}
|
|
onCancel={() => setDepOpen(false)}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
}
|