* 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
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import {
|
|
RefreshCw,
|
|
Plus,
|
|
Search,
|
|
Pencil,
|
|
Check,
|
|
AlertCircle,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
import { SelectDropdown } from "../ui/SelectDropdown";
|
|
import { Tooltip } from "../ui/Tooltip";
|
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
|
import * as cmd from "../../lib/commands";
|
|
import { SchemaMenu } from "./SchemaMenu";
|
|
import { getCapabilities } from "../../lib/dbCapabilities";
|
|
import type { DbType } from "../../lib/types";
|
|
|
|
export function DbViewerToolbar({
|
|
databases,
|
|
currentDatabase,
|
|
setCurrentDatabase,
|
|
schemas,
|
|
currentSchema,
|
|
setCurrentSchema,
|
|
onEdit,
|
|
connectionId,
|
|
dbType,
|
|
searchQuery,
|
|
onSearchChange,
|
|
}: {
|
|
databases: string[];
|
|
currentDatabase: string | null;
|
|
setCurrentDatabase: (db: string | null) => void;
|
|
schemas: string[];
|
|
currentSchema: string | null;
|
|
setCurrentSchema: (schema: string | null) => void;
|
|
onEdit?: () => void;
|
|
connectionId?: string;
|
|
dbType?: DbType;
|
|
searchQuery: string;
|
|
onSearchChange: (q: string) => void;
|
|
}) {
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [result, setResult] = useState<"idle" | "success" | "error">("idle");
|
|
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
|
const populate = useDbViewerStore((s) => s.populate);
|
|
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
|
|
|
// Focus input when search opens
|
|
useEffect(() => {
|
|
if (searchOpen && searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, [searchOpen]);
|
|
|
|
// Auto-hide on blur when empty
|
|
const handleSearchBlur = useCallback(() => {
|
|
// Small delay to allow clicks on clear button / search icon
|
|
setTimeout(() => {
|
|
if (!searchQuery.trim()) {
|
|
setSearchOpen(false);
|
|
}
|
|
}, 150);
|
|
}, [searchQuery]);
|
|
|
|
const toggleSearch = useCallback(() => {
|
|
setSearchOpen((prev) => {
|
|
const next = !prev;
|
|
if (!next) onSearchChange(""); // clear when closing
|
|
return next;
|
|
});
|
|
}, [onSearchChange]);
|
|
|
|
// Cleanup result timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (resultTimer.current) clearTimeout(resultTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
const handleCreateTable = useCallback(() => {
|
|
const schema = currentSchema ?? "public";
|
|
useDbViewerStore.getState().openFormTab({
|
|
kind: "table",
|
|
schema,
|
|
name: "",
|
|
title: "Create Table",
|
|
description: "Create Table",
|
|
mode: "create",
|
|
params: { schema, name: "", action: { op: "create", columns: [] } },
|
|
});
|
|
}, [currentSchema]);
|
|
|
|
const showCreateTable = getCapabilities(dbType ?? "postgresql").tableManagement;
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
if (!connectionId || refreshing) return;
|
|
setRefreshing(true);
|
|
setResult("idle");
|
|
try {
|
|
const dbs = await cmd.getDatabases(connectionId);
|
|
const scs = await cmd.getSchemas(connectionId);
|
|
const tbls = await cmd.getTables(connectionId);
|
|
populate(dbs, scs, tbls);
|
|
setResult("success");
|
|
} catch {
|
|
setResult("error");
|
|
} finally {
|
|
setRefreshing(false);
|
|
resultTimer.current = setTimeout(() => setResult("idle"), 1500);
|
|
}
|
|
}, [connectionId, refreshing, populate]);
|
|
|
|
const hasBelow =
|
|
searchOpen || databases.length > 1 || schemas.length > 1 || schemaTreeLoading;
|
|
|
|
return (
|
|
<div
|
|
className={`px-3 pt-3 border-b border-border space-y-2 ${hasBelow ? "pb-3" : ""}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-normal text-text-muted">Tables</span>
|
|
<div className="flex items-center gap-1">
|
|
{onEdit && (
|
|
<Tooltip content="Edit Connection" side="bottom">
|
|
<button
|
|
aria-label="Edit Connection"
|
|
onClick={onEdit}
|
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip
|
|
content={
|
|
result === "success"
|
|
? "Refreshed"
|
|
: result === "error"
|
|
? "Refresh failed"
|
|
: "Refresh Database"
|
|
}
|
|
side="bottom"
|
|
>
|
|
<button
|
|
aria-label="Refresh"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
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"
|
|
>
|
|
{refreshing ? (
|
|
<RefreshCw size={14} className="animate-spin" />
|
|
) : result === "success" ? (
|
|
<Check size={14} className="text-emerald-400" />
|
|
) : result === "error" ? (
|
|
<AlertCircle
|
|
size={14}
|
|
className="text-red-400"
|
|
/>
|
|
) : (
|
|
<RefreshCw size={14} />
|
|
)}
|
|
</button>
|
|
</Tooltip>
|
|
{showCreateTable && (
|
|
<Tooltip content="Create Table" side="bottom">
|
|
<button
|
|
aria-label="Create Table"
|
|
onClick={handleCreateTable}
|
|
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>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip content="Search Tables" side="bottom">
|
|
<button
|
|
aria-label="Search Tables"
|
|
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>
|
|
</Tooltip>
|
|
</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) => onSearchChange(e.target.value)}
|
|
onBlur={handleSearchBlur}
|
|
placeholder="Filter tables…"
|
|
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={() => onSearchChange("")}
|
|
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>
|
|
{(databases.length > 1 || schemas.length > 1 || schemaTreeLoading) && (
|
|
<div className="flex items-center gap-2">
|
|
{databases.length > 1 && (
|
|
<SelectDropdown
|
|
value={currentDatabase ?? ""}
|
|
onChange={setCurrentDatabase}
|
|
options={databases.map((d) => ({
|
|
value: d,
|
|
label: d,
|
|
}))}
|
|
placeholder="Select database"
|
|
aria-label="Select database"
|
|
variant="ghost"
|
|
/>
|
|
)}
|
|
{databases.length > 1 && (schemas.length > 1 || schemaTreeLoading) && (
|
|
<span className="text-border">|</span>
|
|
)}
|
|
{(schemas.length > 1 || schemaTreeLoading) && (
|
|
<SelectDropdown
|
|
value={schemaTreeLoading ? "" : (currentSchema ?? "")}
|
|
onChange={schemaTreeLoading ? () => {} : setCurrentSchema}
|
|
options={
|
|
schemaTreeLoading
|
|
? [{ value: "", label: "Loading…" }]
|
|
: schemas.map((s) => ({ value: s, label: s }))
|
|
}
|
|
placeholder={schemaTreeLoading ? "Loading…" : "Select schema"}
|
|
aria-label="Select schema"
|
|
variant="ghost"
|
|
disabled={schemaTreeLoading}
|
|
/>
|
|
)}
|
|
<SchemaMenu
|
|
connectionId={connectionId ?? ""}
|
|
schema={currentSchema ?? undefined}
|
|
onRefresh={handleRefresh}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|