Files
gridline/src/components/db-viewer/TableOverflowMenu.tsx
T
adrianbonpin 04e8ed3300 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.
2026-08-05 20:46:36 +08:00

225 lines
7.0 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { MoreVertical } from "lucide-react";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { ImportDialog } from "./ImportDialog";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands";
import { DependencyDialog } from "./DependencyDialog";
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
interface TableOverflowMenuProps {
schema: string;
table: string;
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
connectionId?: string;
columns?: ColumnInfo[];
rows?: unknown[][];
}
interface MenuItem {
id: string;
label: string;
danger?: boolean;
}
export function TableOverflowMenu({
schema,
table,
onOpenTab,
connectionId: connectionIdProp,
columns,
rows,
}: TableOverflowMenuProps) {
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
const connectionId = connectionIdProp ?? storeConnectionId;
const addChange = useDbViewerStore((s) => s.addChange);
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);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const handleAction = async (id: string) => {
switch (id) {
case "open":
onOpenTab(schema, table, true);
setOpen(false);
break;
case "copy-schema": {
if (!connectionId) break;
try {
const ddl = await cmd.getTableDdl(connectionId, schema, table);
if (navigator.clipboard) {
void navigator.clipboard.writeText(ddl);
}
} catch {
/* ignore copy failures */
}
setOpen(false);
break;
}
case "export-csv":
case "export-json":
case "export-sql":
case "export-md": {
const format = id.replace("export-", "");
if (rows && rows.length > 0 && columns && columns.length > 0) {
exportData(rows, columns, format, `${schema}.${table}`);
}
setOpen(false);
break;
}
case "import":
setImportOpen(true);
setOpen(false);
break;
case "empty":
setConfirmAction("empty");
setOpen(false);
break;
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;
}
};
const items: MenuItem[] = [
{ id: "open", label: "Open in new tab" },
{ id: "copy-schema", label: "Copy table schema" },
{ id: "export-csv", label: "Export data (CSV)" },
{ id: "export-json", label: "Export data (JSON)" },
{ id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" },
{ id: "import", label: "Import data (CSV/JSON)" },
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
return (
<div className="relative" ref={menuRef}>
<button
aria-label="Table options"
onClick={() => setOpen((o) => !o)}
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical size={14} />
</button>
{open && (
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleAction(item.id)}
className={[
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<span>{item.label}</span>
</button>
))}
</div>
)}
{confirmAction === "delete" && dropDeps.length > 0 && (
<DependencyDialog
open
deps={dropDeps}
onProceed={() => setConfirmAction(null)}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "empty" && (
<ConfirmDialog
open
title={`Empty Table: ${table}`}
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
confirmLabel="Empty Table"
onConfirm={() => {
addChange({
type: "empty_table",
schema,
table,
description: `Empty Table: ${schema}.${table}`,
});
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "delete" && (
<ConfirmDialog
open
title={`Delete Table: ${table}`}
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
confirmLabel="Delete Table"
onConfirm={() => {
addChange({
type: "drop_table",
schema,
table,
description: `Drop Table: ${schema}.${table}`,
});
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
<ImportDialog
open={importOpen}
schema={schema}
table={table}
columns={columns?.map((c) => c.name) ?? []}
onStage={(change) => {
addChange({
type: "bulk_insert",
schema: change.schema,
table: change.table,
columns: change.columns,
rows: change.rows,
description: change.description,
});
setImportOpen(false);
}}
onClose={() => setImportOpen(false)}
/>
</div>
);
}