v0.7.5: bundled PG tools, schema CRUD, object search, copy-as-DDL, object dependencies (#11)
* chore: bump version to 0.7.5 (Task 1.1) * feat(db): pure object DDL/search/depend builders (Task 1.2) * feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3) * feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1) * feat(objects): schema CRUD commands + integration test (Task 2.2) * feat(objects): search_objects command (current-schema, all types) (Task 2.3) * feat(objects): get_object_ddl for all browsable types (Task 2.4) * feat(objects): pg_depend object dependencies + schema contents (Task 2.5) * feat(ipc): register object management commands (Task 3.1) * feat(ipc): frontend wrappers + types for object management (Task 3.2) * feat(build): declare bundled pg_tools resources (Task 3.3) * test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4) * feat(ui): Cmd+K object search palette in DB viewer (Task 4.1) * feat(ui): schema CRUD menu + dependency dialog (Task 4.2) * feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3) * feat(ui): dependency check before table drop + bundled-tool status (Task 4.4) * docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1) * ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2) * fix(objects): report pg_rewrite dependencies as the dependent view (pg_class) pg_depend records view dependencies via the view's internal rewrite rule (classid = pg_rewrite). The user-facing dependent object is the VIEW itself, so map that classid to pg_class (name still resolved through ev_class). Fixes the live-PG integration test object_dependencies_for_table_includes_view and makes the dependency dialog readable for view dependents. * test: add idempotent PG integration-test seed script Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the #[ignore] integration tests assert against: users (+users_id_seq), products (3 rows), orders (3 rows), order_summary view, audit_log + get_user functions, user_role enum. Idempotent: DROP + recreate. * fix(build): regenerate multi-resolution icons + roadmap Keychain item - icon.ico previously contained a single 16x16 frame (Windows scaled it up -> blurry taskbar/start-menu icon). Regenerated from the 512px source via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full @2x coverage up to 1024px, PNGs re-rendered from the same source. - Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs a high-DPI entry. - ROADMAP: log the 'Enable Keychain' toggle (currently a form-only placeholder) under Next up -> Connection & credentials. * docs(readme): dynamic version-free download links + 3-col table - release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]' (version-free), so asset filenames are stable across releases: Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe, Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc. - README: both download tables now 3-column (OS | Architecture | Download) and link via GitHub's releases/latest/download/<file> redirect — they always point at the newest published release, no per-release edits. - AGENTS.md: releases checklist updated — download links stay version-free. * docs(readme): platform-per-column download table (macOS | Windows | Linux) Table now mirrors the release layout: one column per OS with a logo row and a download-links row underneath. Links stay version-free via releases/latest/download/<file> (releaseAssetNamePattern in release.yml). * docs(readme): revert download table to clean image-less 3-col layout Logo row was hard to read on GitHub dark mode; the plain OS | Architecture | Download table is cleaner and still uses dynamic releases/latest links.
This commit is contained in:
@@ -58,7 +58,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, [open]);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
|
||||
|
||||
describe("BackupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
});
|
||||
|
||||
it("hides install instructions when tools are bundled", async () => {
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: "bundled",
|
||||
pg_restore_source: "bundled",
|
||||
});
|
||||
render(<BackupPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
|
||||
expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,8 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
@@ -136,6 +138,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
||||
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -160,7 +163,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && (
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
pg_dump not found
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ToolsPage } from "./ToolsPage";
|
||||
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||
import { QueriesPanel } from "../queries/QueriesPanel";
|
||||
import { useQueryStore } from "../../stores/queryStore";
|
||||
import { ObjectSearchPalette } from "./ObjectSearchPalette";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { EnumInfo } from "../../lib/types";
|
||||
import type { FkOption } from "../grid/CellEditor";
|
||||
@@ -186,6 +187,8 @@ export function DbViewerScreen({
|
||||
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
|
||||
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
|
||||
|
||||
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
|
||||
|
||||
// Sync settings defaults to store
|
||||
useEffect(() => {
|
||||
if (settings?.table_page_size) {
|
||||
@@ -423,6 +426,11 @@ export function DbViewerScreen({
|
||||
onHome();
|
||||
}
|
||||
});
|
||||
useShortcut("command_palette", () => {
|
||||
if (capabilities.objects) {
|
||||
setObjectSearchOpen(true);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (activeTab.tabType !== "table") return;
|
||||
@@ -1455,6 +1463,9 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
{capabilities.objects && (
|
||||
<ObjectSearchPalette connectionId={connectionId} />
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
|
||||
export function DbViewerToolbar({
|
||||
databases,
|
||||
@@ -227,6 +228,11 @@ export function DbViewerToolbar({
|
||||
disabled={schemaTreeLoading}
|
||||
/>
|
||||
)}
|
||||
<SchemaMenu
|
||||
connectionId={connectionId ?? ""}
|
||||
schema={currentSchema ?? undefined}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
|
||||
describe("DependencyDialog", () => {
|
||||
it("empty list shows no-dependencies message", () => {
|
||||
render(<DependencyDialog open deps={[]} onProceed={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText(/no dependencies/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("non-empty lists rows and requires checkbox to proceed", () => {
|
||||
const deps = [{ deptype: "n", class: "pg_class", name: "v_orders" }];
|
||||
render(<DependencyDialog open deps={deps} onProceed={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText("v_orders")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /proceed/i })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(screen.getByRole("button", { name: /proceed/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { DependencyInfo } from "../../lib/types";
|
||||
|
||||
interface DependencyDialogProps {
|
||||
open: boolean;
|
||||
deps: DependencyInfo[];
|
||||
onProceed: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DependencyDialog({ open, deps, onProceed, onCancel }: DependencyDialogProps) {
|
||||
const [ack, setAck] = useState(false);
|
||||
const hasDeps = deps.length > 0;
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onCancel}>
|
||||
<div className="w-[420px]">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Dependencies</h3>
|
||||
{hasDeps ? (
|
||||
<>
|
||||
<p className="text-sm text-red-400 mb-2">
|
||||
The following depend on this object and will be removed with CASCADE:
|
||||
</p>
|
||||
<ul className="max-h-48 overflow-auto my-2 space-y-1 pr-1">
|
||||
{deps.map((d, i) => (
|
||||
<li key={i} className="text-sm text-text">
|
||||
{d.name}{" "}
|
||||
<span className="text-text-subtle">({d.class})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="flex items-center gap-2 text-sm text-text-muted mt-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ack}
|
||||
onChange={(e) => setAck(e.target.checked)}
|
||||
className="accent-accent h-4 w-4"
|
||||
/>
|
||||
I understand these will be dropped.
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No dependencies — safe to drop.</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onProceed}
|
||||
disabled={hasDeps && !ack}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
@@ -189,4 +189,57 @@ describe("ObjectExplorerPage", () => {
|
||||
);
|
||||
expect(screen.queryByText("calc")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Copy DDL calls getObjectDdl and writes to clipboard", async () => {
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||
{ name: "role", schema: "public", labels: ["a"] },
|
||||
]);
|
||||
vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ...");
|
||||
const writeText = vi.fn();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
await waitFor(() => screen.getByText("role"));
|
||||
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
|
||||
fireEvent.click(screen.getByText(/copy ddl/i));
|
||||
await waitFor(() =>
|
||||
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith("CREATE TYPE ...");
|
||||
});
|
||||
|
||||
it("View dependencies opens DependencyDialog", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "add_one",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: ["int"],
|
||||
argument_names: ["x"],
|
||||
argument_modes: ["IN"],
|
||||
language: "sql",
|
||||
source: "SELECT $1 + 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([
|
||||
{ deptype: "n", class: "pg_class", name: "v" },
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() => screen.getByText("add_one(int)"));
|
||||
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
|
||||
fireEvent.click(screen.getByText(/dependencies/i));
|
||||
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("preselects type from store on mount", () => {
|
||||
useDbViewerStore.setState({ selectedObjectType: "sequences" });
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
expect(screen.getByText("Sequences")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
MoreVertical,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
Puzzle,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type {
|
||||
FunctionInfo,
|
||||
@@ -24,17 +26,11 @@ import type {
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
ObjectType,
|
||||
DependencyInfo,
|
||||
} from "../../lib/types";
|
||||
|
||||
export type ObjectType =
|
||||
| "functions"
|
||||
| "triggers"
|
||||
| "sequences"
|
||||
| "enums"
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures";
|
||||
|
||||
|
||||
interface ObjectExplorerPageProps {
|
||||
connectionId: string;
|
||||
@@ -120,6 +116,28 @@ function itemLabel(item: AnyObject): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function schemaOf(item: AnyObject): string {
|
||||
return item.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",
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
|
||||
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
|
||||
|
||||
const SQL_KEYWORDS = new Set([
|
||||
@@ -1020,7 +1038,8 @@ function renderDetail(type: ObjectType, item: AnyObject) {
|
||||
}
|
||||
|
||||
export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
const [type, setType] = useState<ObjectType>("functions");
|
||||
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,
|
||||
@@ -1061,6 +1080,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
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);
|
||||
@@ -1173,14 +1195,25 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
// 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 = (next: ObjectType) => {
|
||||
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">
|
||||
@@ -1326,6 +1359,34 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
const isSelected =
|
||||
selectedItem !== null &&
|
||||
itemKey(selectedItem) === itemKey(item);
|
||||
const menuOpen = openKey === key;
|
||||
|
||||
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
|
||||
@@ -1341,6 +1402,44 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
<span className="flex-1 text-sm truncate">
|
||||
{name}
|
||||
</span>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative"
|
||||
>
|
||||
<button
|
||||
aria-label="options"
|
||||
onClick={() =>
|
||||
setOpenKey((k) =>
|
||||
k === key ? null : key,
|
||||
)
|
||||
}
|
||||
className={`w-6 h-6 rounded flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer transition-opacity ${
|
||||
menuOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 mt-1 z-20 min-w-[140px] rounded-md bg-surface border border-border shadow-lg py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyDdl}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
Copy DDL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewDependencies}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
Dependencies
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-text-muted shrink-0 transition-transform ${
|
||||
@@ -1399,6 +1498,13 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DependencyDialog
|
||||
open={depOpen}
|
||||
deps={depDeps}
|
||||
onProceed={() => setDepOpen(false)}
|
||||
onCancel={() => setDepOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectSearchPalette } from "./ObjectSearchPalette";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ObjectSearchHit } from "../../lib/types";
|
||||
|
||||
vi.mock("../../lib/commands");
|
||||
|
||||
const mockSetObjectSearchOpen = vi.fn();
|
||||
const mockSetCurrentSchema = vi.fn();
|
||||
const mockSetSelectedObjectType = vi.fn();
|
||||
const mockOpenTab = vi.fn();
|
||||
|
||||
const baseMockState = {
|
||||
objectSearchOpen: true,
|
||||
setObjectSearchOpen: mockSetObjectSearchOpen,
|
||||
currentSchema: "public" as string | null,
|
||||
setCurrentSchema: mockSetCurrentSchema,
|
||||
setSelectedObjectType: mockSetSelectedObjectType,
|
||||
openTab: mockOpenTab,
|
||||
};
|
||||
|
||||
let mockState: typeof baseMockState = { ...baseMockState };
|
||||
|
||||
vi.mock("../../stores/dbViewerStore", () => ({
|
||||
useDbViewerStore: (selector: unknown) => {
|
||||
return typeof selector === "function"
|
||||
? (selector as (s: typeof mockState) => unknown)(mockState)
|
||||
: mockState[selector as keyof typeof mockState];
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ObjectSearchPalette", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = { ...baseMockState };
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
mockState = { ...baseMockState, objectSearchOpen: false };
|
||||
const { container } = render(<ObjectSearchPalette connectionId="c1" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("debounces and groups results by type", async () => {
|
||||
const hits: ObjectSearchHit[] = [
|
||||
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||
{ name: "get_user", schema: "public", object_type: "FUNCTION" },
|
||||
];
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue(hits);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "user" },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "user"),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TABLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("FUNCTION")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses currentSchema fallback when store value is null", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||
mockState = { ...baseMockState, currentSchema: null };
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "x"),
|
||||
);
|
||||
});
|
||||
|
||||
it("Esc closes the palette", () => {
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("backdrop click closes the palette", () => {
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
const backdrop = screen.getByLabelText(/search objects/i).closest(
|
||||
"div[class*='fixed inset-0']",
|
||||
) as HTMLElement;
|
||||
fireEvent.click(backdrop);
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("selecting a table opens a tab and closes", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "users" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("users"));
|
||||
fireEvent.click(screen.getByText("users"));
|
||||
|
||||
expect(mockOpenTab).toHaveBeenCalledWith("public", "users");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetCurrentSchema).not.toHaveBeenCalled();
|
||||
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selecting a view opens a tab and closes", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "active_users", schema: "public", object_type: "VIEW" },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "active" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("active_users"));
|
||||
fireEvent.click(screen.getByText("active_users"));
|
||||
|
||||
expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("selecting a matview opens a tab and closes", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "mv_users", schema: "public", object_type: "MATERIALIZED VIEW" },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "mv" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("mv_users"));
|
||||
fireEvent.click(screen.getByText("mv_users"));
|
||||
|
||||
expect(mockOpenTab).toHaveBeenCalledWith("public", "mv_users");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["FUNCTION", "functions"],
|
||||
["PROCEDURE", "procedures"],
|
||||
["TRIGGER", "triggers"],
|
||||
["SEQUENCE", "sequences"],
|
||||
["ENUM", "enums"],
|
||||
["EXTENSION", "extensions"],
|
||||
["INDEX", "indexes"],
|
||||
["CONSTRAINT", "constraints"],
|
||||
] as const)(
|
||||
"selecting a %s switches the objects view and closes",
|
||||
async (objectType, mappedType) => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "item", schema: "app", object_type: objectType },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "item" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("item"));
|
||||
fireEvent.click(screen.getByText("item"));
|
||||
|
||||
expect(mockSetCurrentSchema).toHaveBeenCalledWith("app");
|
||||
expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType);
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockOpenTab).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows an empty state when no results match", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "nomatch" },
|
||||
});
|
||||
await waitFor(() => expect(cmd.searchObjects).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no matches/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears query and results when reopened", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||
]);
|
||||
const { rerender } = render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "users" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("users"));
|
||||
|
||||
mockState = { ...baseMockState, objectSearchOpen: false };
|
||||
rerender(<ObjectSearchPalette connectionId="c1" />);
|
||||
|
||||
mockState = { ...baseMockState, objectSearchOpen: true };
|
||||
rerender(<ObjectSearchPalette connectionId="c1" />);
|
||||
|
||||
expect(screen.queryByText("users")).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ObjectSearchHit, ObjectType } from "../../lib/types";
|
||||
|
||||
const TYPE_TO_OBJECTS: Record<string, ObjectType> = {
|
||||
FUNCTION: "functions",
|
||||
PROCEDURE: "procedures",
|
||||
TRIGGER: "triggers",
|
||||
SEQUENCE: "sequences",
|
||||
ENUM: "enums",
|
||||
EXTENSION: "extensions",
|
||||
INDEX: "indexes",
|
||||
CONSTRAINT: "constraints",
|
||||
};
|
||||
|
||||
interface ObjectSearchPaletteProps {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps) {
|
||||
const open = useDbViewerStore((s) => s.objectSearchOpen);
|
||||
const setOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
|
||||
const storeSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const currentSchema = storeSchema ?? "public";
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<ObjectSearchHit[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clear the transient state whenever the palette is closed so it reopens
|
||||
// with an empty search and no stale results.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setHits([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Debounced search against the current schema.
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
setHits([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await cmd.searchObjects(
|
||||
connectionId,
|
||||
currentSchema,
|
||||
query,
|
||||
);
|
||||
setHits(results);
|
||||
} catch {
|
||||
setHits([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [query, connectionId, currentSchema]);
|
||||
|
||||
// Esc closes the palette.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const grouped = hits.reduce<Record<string, ObjectSearchHit[]>>((acc, hit) => {
|
||||
const list = acc[hit.object_type] ?? [];
|
||||
list.push(hit);
|
||||
acc[hit.object_type] = list;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const handleSelect = (hit: ObjectSearchHit) => {
|
||||
if (
|
||||
hit.object_type === "TABLE" ||
|
||||
hit.object_type === "VIEW" ||
|
||||
hit.object_type === "MATERIALIZED VIEW"
|
||||
) {
|
||||
openTab(hit.schema, hit.name);
|
||||
} else {
|
||||
setCurrentSchema(hit.schema);
|
||||
setSelectedObjectType(
|
||||
TYPE_TO_OBJECTS[hit.object_type] ?? "functions",
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24"
|
||||
onClick={() => setOpen(false)}
|
||||
role="dialog"
|
||||
aria-label="Search objects"
|
||||
>
|
||||
<div
|
||||
className="w-[520px] max-h-[60vh] overflow-auto rounded-xl border border-border bg-surface shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Search size={14} className="text-text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search objects in current schema…"
|
||||
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
{loading && (
|
||||
<span className="text-xs text-text-muted">loading</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).map(([type, list]) => (
|
||||
<div key={type}>
|
||||
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
|
||||
{type}
|
||||
</div>
|
||||
{list.map((hit) => (
|
||||
<button
|
||||
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
|
||||
type="button"
|
||||
onClick={() => handleSelect(hit)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-text hover:bg-surface-raised"
|
||||
>
|
||||
<span className="truncate">{hit.name}</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{hit.schema}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && query.trim() && hits.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-text-muted">No matches</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
|
||||
setConfirmed(false);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, [open]);
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
@@ -121,6 +123,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
|
||||
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
|
||||
const canStart = filePath && confirmed && !isRunning;
|
||||
|
||||
return (
|
||||
@@ -146,7 +149,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && (
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
pg_restore not found
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { SchemaMenu } from "./SchemaMenu";
|
||||
|
||||
vi.mock("../../lib/commands");
|
||||
|
||||
describe("SchemaMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cmd.createSchema).mockReset();
|
||||
vi.mocked(cmd.renameSchema).mockReset();
|
||||
vi.mocked(cmd.dropSchema).mockReset();
|
||||
vi.mocked(cmd.getObjectDependencies).mockReset();
|
||||
});
|
||||
|
||||
it("create happy path calls createSchema then refreshTree", async () => {
|
||||
vi.mocked(cmd.createSchema).mockResolvedValue(undefined);
|
||||
const refresh = vi.fn();
|
||||
render(<SchemaMenu connectionId="c1" onRefresh={refresh} />);
|
||||
fireEvent.click(screen.getByLabelText(/new schema/i));
|
||||
fireEvent.change(screen.getByPlaceholderText(/schema name/i), { target: { value: "myschema" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => expect(cmd.createSchema).toHaveBeenCalledWith("c1", "myschema"));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("drop non-empty requires cascade checkbox + typed confirm", async () => {
|
||||
vi.mocked(cmd.getObjectDependencies).mockResolvedValue([{ deptype: "n", class: "pg_class", name: "users" }]);
|
||||
vi.mocked(cmd.dropSchema).mockResolvedValue(undefined);
|
||||
const refresh = vi.fn();
|
||||
render(<SchemaMenu connectionId="c1" schema="s" onRefresh={refresh} />);
|
||||
fireEvent.click(screen.getByLabelText(/schema menu/i));
|
||||
fireEvent.click(screen.getByText(/drop/i));
|
||||
await waitFor(() => expect(cmd.getObjectDependencies).toHaveBeenCalledWith("c1", "s", "schema", "s"));
|
||||
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.change(screen.getByPlaceholderText(/type the schema name/i), { target: { value: "s" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /drop schema/i }));
|
||||
await waitFor(() => expect(cmd.dropSchema).toHaveBeenCalledWith("c1", "s", true));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Plus, MoreVertical } from "lucide-react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { DependencyInfo } from "../../lib/types";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
|
||||
interface SchemaMenuProps {
|
||||
connectionId: string;
|
||||
schema?: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
const [deps, setDeps] = useState<DependencyInfo[]>([]);
|
||||
const [typed, setTyped] = useState("");
|
||||
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [menuOpen]);
|
||||
|
||||
const resetErrors = () => setErr(null);
|
||||
|
||||
const create = async () => {
|
||||
resetErrors();
|
||||
try {
|
||||
await cmd.createSchema(connectionId, name);
|
||||
setCreating(false);
|
||||
setName("");
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
resetErrors();
|
||||
if (!schema) return;
|
||||
try {
|
||||
await cmd.renameSchema(connectionId, schema, newName);
|
||||
setRenaming(false);
|
||||
setNewName("");
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const startDrop = async () => {
|
||||
setMenuOpen(false);
|
||||
if (!schema) return;
|
||||
try {
|
||||
const d = await cmd.getObjectDependencies(connectionId, schema, "schema", schema);
|
||||
setDeps(d);
|
||||
setDropOpen(true);
|
||||
setTyped("");
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDrop = async (cascade: boolean) => {
|
||||
if (!schema) return;
|
||||
try {
|
||||
await cmd.dropSchema(connectionId, schema, cascade);
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setDeps([]);
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const hasDeps = deps.length > 0;
|
||||
|
||||
const inputClass =
|
||||
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1" ref={menuRef}>
|
||||
<button
|
||||
aria-label="New schema"
|
||||
onClick={() => {
|
||||
setCreating(true);
|
||||
setName("");
|
||||
setErr(null);
|
||||
}}
|
||||
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>
|
||||
|
||||
{schema && (
|
||||
<button
|
||||
aria-label="Schema menu"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setRenaming(true);
|
||||
setNewName("");
|
||||
setErr(null);
|
||||
}}
|
||||
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startDrop}
|
||||
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
Drop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Schema */}
|
||||
<AnimatedModal open={creating} onClose={() => setCreating(false)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">New Schema</h3>
|
||||
<p className="text-sm text-text-muted mb-3">Create a new schema in the current database.</p>
|
||||
<input
|
||||
placeholder="Schema name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={create}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
|
||||
{/* Rename Schema */}
|
||||
<AnimatedModal open={renaming} onClose={() => setRenaming(false)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Rename {schema}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">Enter the new name for this schema.</p>
|
||||
<input
|
||||
placeholder="New name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={() => setRenaming(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={rename}>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
|
||||
{/* Dependency warning */}
|
||||
{dropOpen && (
|
||||
<DependencyDialog
|
||||
open={dropOpen}
|
||||
deps={deps}
|
||||
onCancel={() => {
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setDeps([]);
|
||||
setErr(null);
|
||||
}}
|
||||
onProceed={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Typed-name confirmation for CASCADE drop */}
|
||||
{dropOpen && hasDeps && (
|
||||
<AnimatedModal
|
||||
open={dropOpen}
|
||||
onClose={() => {
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Type the schema name to confirm the CASCADE drop.
|
||||
</p>
|
||||
<input
|
||||
placeholder="Type the schema name"
|
||||
value={typed}
|
||||
onChange={(e) => {
|
||||
setTyped(e.target.value);
|
||||
if (err) setErr(null);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (typed !== schema) {
|
||||
setErr("Name does not match");
|
||||
return;
|
||||
}
|
||||
void confirmDrop(true);
|
||||
}}
|
||||
>
|
||||
Drop Schema
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
|
||||
{/* Empty-schema confirmation (no deps) */}
|
||||
{dropOpen && !hasDeps && (
|
||||
<AnimatedModal
|
||||
open={dropOpen}
|
||||
onClose={() => {
|
||||
setDropOpen(false);
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">No dependencies — drop this empty schema?</p>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropOpen(false);
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void confirmDrop(false)}>
|
||||
Drop Schema
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
|
||||
setConfirmed(false);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, [open]);
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ export function SyncPage() {
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
@@ -100,6 +102,9 @@ export function SyncPage() {
|
||||
const toolsMissing =
|
||||
toolStatus &&
|
||||
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
|
||||
const toolsBundled =
|
||||
toolStatus?.pg_dump_source === "bundled" &&
|
||||
toolStatus?.pg_restore_source === "bundled";
|
||||
const canStart =
|
||||
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
|
||||
|
||||
@@ -130,7 +135,7 @@ export function SyncPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && (
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
PostgreSQL tools not found
|
||||
|
||||
@@ -63,9 +63,11 @@ describe("TableOverflowMenu", () => {
|
||||
});
|
||||
|
||||
it("Delete Table opens confirm then stages a drop_table change", async () => {
|
||||
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([]);
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/delete table/i));
|
||||
await waitFor(() => expect(screen.queryByText(/open in new tab/i)).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
@@ -73,6 +75,15 @@ describe("TableOverflowMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("Delete Table fetches dependencies and shows DependencyDialog before confirming", async () => {
|
||||
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([{ deptype: "n", class: "pg_class", name: "v_orders" }]);
|
||||
render(<TableOverflowMenu schema="public" table="orders" connectionId="c1" onOpenTab={() => "tab-1"} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/delete table/i));
|
||||
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalledWith("c1", "public", "table", "orders"));
|
||||
await waitFor(() => expect(screen.getByText("v_orders")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("Export data calls exportData when rows and columns are provided", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData");
|
||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
@@ -37,6 +38,7 @@ export function TableOverflowMenu({
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -98,10 +100,18 @@ export function TableOverflowMenu({
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
break;
|
||||
case "delete":
|
||||
case "delete": {
|
||||
if (!connectionId) break;
|
||||
try {
|
||||
const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table);
|
||||
setDropDeps(deps);
|
||||
} catch {
|
||||
setDropDeps([]);
|
||||
}
|
||||
setConfirmAction("delete");
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -146,6 +156,15 @@ export function TableOverflowMenu({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction === "delete" && dropDeps.length > 0 && (
|
||||
<DependencyDialog
|
||||
open
|
||||
deps={dropDeps}
|
||||
onProceed={() => setConfirmAction(null)}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmAction === "empty" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.5)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.5", () => {
|
||||
expect(tauriConf.version).toBe("0.7.5");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
clearRecentConnections,
|
||||
getIndexes,
|
||||
getConstraints,
|
||||
createSchema,
|
||||
renameSchema,
|
||||
dropSchema,
|
||||
searchObjects,
|
||||
getObjectDdl,
|
||||
getObjectDependencies,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
@@ -291,4 +297,39 @@ describe("v0.5.0 command wrappers", () => {
|
||||
await getConstraints("c1", "public");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("object management commands (v0.7.5)", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("createSchema calls invoke with name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await createSchema("c1", "my_schema");
|
||||
expect(invoke).toHaveBeenCalledWith("create_schema", { connectionId: "c1", name: "my_schema" });
|
||||
});
|
||||
it("renameSchema maps old/new names", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await renameSchema("c1", "old", "new");
|
||||
expect(invoke).toHaveBeenCalledWith("rename_schema", { connectionId: "c1", oldName: "old", newName: "new" });
|
||||
});
|
||||
it("dropSchema passes cascade", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await dropSchema("c1", "s", true);
|
||||
expect(invoke).toHaveBeenCalledWith("drop_schema", { connectionId: "c1", name: "s", cascade: true });
|
||||
});
|
||||
it("searchObjects maps query+schema", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await searchObjects("c1", "public", "user");
|
||||
expect(invoke).toHaveBeenCalledWith("search_objects", { connectionId: "c1", schema: "public", query: "user" });
|
||||
});
|
||||
it("getObjectDdl maps objectType+name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce("CREATE SEQUENCE ...");
|
||||
await getObjectDdl("c1", "public", "sequence", "users_id_seq");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_ddl", { connectionId: "c1", schema: "public", objectType: "sequence", name: "users_id_seq" });
|
||||
});
|
||||
it("getObjectDependencies maps objectType+name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await getObjectDependencies("c1", "public", "table", "orders");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" });
|
||||
});
|
||||
});
|
||||
+22
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
|
||||
@@ -298,4 +298,25 @@ export async function getIndexes(connectionId: string, schema?: string): Promise
|
||||
|
||||
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
|
||||
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
|
||||
}
|
||||
|
||||
// ─── v0.7.5: Object management (schemas, search, DDL, dependencies) ──
|
||||
|
||||
export async function createSchema(connectionId: string, name: string): Promise<void> {
|
||||
return invoke<void>("create_schema", { connectionId, name });
|
||||
}
|
||||
export async function renameSchema(connectionId: string, oldName: string, newName: string): Promise<void> {
|
||||
return invoke<void>("rename_schema", { connectionId, oldName, newName });
|
||||
}
|
||||
export async function dropSchema(connectionId: string, name: string, cascade: boolean): Promise<void> {
|
||||
return invoke<void>("drop_schema", { connectionId, name, cascade });
|
||||
}
|
||||
export async function searchObjects(connectionId: string, schema: string, query: string): Promise<ObjectSearchHit[]> {
|
||||
return invoke<ObjectSearchHit[]>("search_objects", { connectionId, schema, query });
|
||||
}
|
||||
export async function getObjectDdl(connectionId: string, schema: string, objectType: string, name: string): Promise<string> {
|
||||
return invoke<string>("get_object_ddl", { connectionId, schema, objectType, name });
|
||||
}
|
||||
export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> {
|
||||
return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name });
|
||||
}
|
||||
@@ -50,4 +50,11 @@ describe("dbCapabilities", () => {
|
||||
expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql);
|
||||
expect(getCapabilities("redis")).toBe(DB_CAPABILITIES.redis);
|
||||
});
|
||||
|
||||
it("objects capability (search/ddl/dependencies) is PG-only", () => {
|
||||
expect(getCapabilities("postgresql").objects).toBe(true);
|
||||
expect(getCapabilities("mysql").objects).toBe(false);
|
||||
expect(getCapabilities("sqlite").objects).toBe(false);
|
||||
expect(getCapabilities("redis").objects).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.0 docs coverage", () => {
|
||||
describe("v0.7.5 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,23 @@ describe("v0.7.0 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.0", () => {
|
||||
expect(readme).toContain("0.7.0");
|
||||
it("README declares v0.7.5", () => {
|
||||
expect(readme).toContain("0.7.5");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks global object search complete", () => {
|
||||
expect(agents).toMatch(/Global object search \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks copy-as-DDL complete", () => {
|
||||
expect(agents).toMatch(/Copy as DDL for any object \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks object dependencies complete", () => {
|
||||
expect(agents).toMatch(/Object dependencies \| ✅/);
|
||||
});
|
||||
it("README notes bundled PostgreSQL tools", () => {
|
||||
expect(readme.toLowerCase()).toContain("bundled");
|
||||
});
|
||||
it("README marks inline editing complete (not Upcoming)", () => {
|
||||
// Key Features lists inline editing as a shipped feature
|
||||
|
||||
@@ -305,6 +305,34 @@ export interface PgToolStatus {
|
||||
pg_restore_found: boolean;
|
||||
pg_dump_version: string | null;
|
||||
pg_restore_version: string | null;
|
||||
pg_dump_source: string | null;
|
||||
pg_restore_source: string | null;
|
||||
}
|
||||
|
||||
export type PgObjectType =
|
||||
| "table" | "view" | "materialized view" | "function" | "procedure"
|
||||
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
|
||||
|
||||
export type ObjectType =
|
||||
| "functions"
|
||||
| "triggers"
|
||||
| "sequences"
|
||||
| "enums"
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures";
|
||||
|
||||
export interface ObjectSearchHit {
|
||||
name: string;
|
||||
schema: string;
|
||||
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||
}
|
||||
|
||||
export interface DependencyInfo {
|
||||
deptype: string;
|
||||
class: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BackupJob {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.0 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.0");
|
||||
it("declares v0.7.5 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.5");
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,22 @@ describe("dbViewerStore", () => {
|
||||
expect(state.tables).toEqual([]);
|
||||
expect(state.currentDatabase).toBeNull();
|
||||
expect(state.currentSchema).toBeNull();
|
||||
expect(state.objectSearchOpen).toBe(false);
|
||||
expect(state.selectedObjectType).toBeNull();
|
||||
expect(state.schemaTreeLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("object search open + selected object type setters", () => {
|
||||
const { setObjectSearchOpen, setSelectedObjectType } =
|
||||
useDbViewerStore.getState();
|
||||
setObjectSearchOpen(true);
|
||||
expect(useDbViewerStore.getState().objectSearchOpen).toBe(true);
|
||||
setSelectedObjectType("functions");
|
||||
expect(useDbViewerStore.getState().selectedObjectType).toBe("functions");
|
||||
setSelectedObjectType(null);
|
||||
expect(useDbViewerStore.getState().selectedObjectType).toBeNull();
|
||||
});
|
||||
|
||||
it("openTab adds a new tab", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────
|
||||
@@ -92,6 +92,8 @@ interface DbViewerState {
|
||||
schemaTreeLoading: boolean;
|
||||
currentDatabase: string | null;
|
||||
currentSchema: string | null;
|
||||
objectSearchOpen: boolean;
|
||||
selectedObjectType: ObjectType | null;
|
||||
functions: FunctionInfo[] | null;
|
||||
triggers: TriggerInfo[] | null;
|
||||
sequences: SequenceInfo[] | null;
|
||||
@@ -141,6 +143,8 @@ interface DbViewerState {
|
||||
toggleChangesPanel: () => void;
|
||||
setCurrentDatabase: (db: string | null) => void;
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
setObjectSearchOpen: (open: boolean) => void;
|
||||
setSelectedObjectType: (t: ObjectType | null) => void;
|
||||
setFunctions: (functions: FunctionInfo[]) => void;
|
||||
setTriggers: (triggers: TriggerInfo[]) => void;
|
||||
setSequences: (sequences: SequenceInfo[]) => void;
|
||||
@@ -179,6 +183,8 @@ const initialState = {
|
||||
tables: [] as TableInfo[],
|
||||
currentDatabase: null as string | null,
|
||||
currentSchema: null as string | null,
|
||||
objectSearchOpen: false,
|
||||
selectedObjectType: null as ObjectType | null,
|
||||
functions: null as FunctionInfo[] | null,
|
||||
triggers: null as TriggerInfo[] | null,
|
||||
sequences: null as SequenceInfo[] | null,
|
||||
@@ -433,6 +439,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
|
||||
setCurrentDatabase: (db) => set({ currentDatabase: db }),
|
||||
setCurrentSchema: (schema) => set({ currentSchema: schema }),
|
||||
setObjectSearchOpen: (open) => set({ objectSearchOpen: open }),
|
||||
setSelectedObjectType: (t) => set({ selectedObjectType: t }),
|
||||
setFunctions: (functions) => set({ functions }),
|
||||
setTriggers: (triggers) => set({ triggers }),
|
||||
setSequences: (sequences) => set({ sequences }),
|
||||
|
||||
Reference in New Issue
Block a user