Files
openidea/src/features/database/treeMenu.ts
T
l0ng-ai 575749f2ac feat(database): Add query plans, history, data tools and schema objects (#63)
New query, data and schema tools for the Database feature (SQLite, PostgreSQL, MySQL).

## Query console
- **Explain Plan** tab: SQLite `EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN (FORMAT JSON)`, MySQL `EXPLAIN`, parsed in `ide-db::plan` into an operation tree (Full Scan / Index Scan / Group By / Order By …) under a statement root, with a properties panel.
- **Query history**: `ide-db::history` keeps executed statements per project and source in `db-history.json` (deduplicated, newest first, 500 max). The Browse Query History dialog has search, a highlighted preview, Paste and Delete.
- Output reports `(execution: X ms, fetching: Y ms)`.

## Results and table editor
- Local column filter (value list with counts) and local sort.
- Record / Value / Aggregates side panel.
- Chart view (bar / line) with X / Y / aggregation settings.
- Export Data dialog: extractor, Transpose, headers, preview, file or clipboard. An extractor drop-down sits on the data toolbars.
- Import Data from a CSV / TSV file.
- Table editor gets a WHERE | ORDER BY row. Column headers fit their names, with filter and sort indicators. Whole SQLite REALs display as `3108`, not `3108.0`.

## Database tree
- Triggers under tables. Routines, sequences and materialized views under schemas, with DDL, Quick Documentation and Drop.
- Data source options: object filter, show template databases, single database mode (PostgreSQL).
- New ▸ Import from Clipboard (connection URLs).

## IPC
New commands: `db_schema_objects`, `db_object_ddl`, `db_explain`, `db_history`, `db_remove_history`. Bindings are regenerated.

## Testing
- `cargo test -p ide-db`: plan parsing, history store, trigger listing and DDL, explain without executing.
- `cargo clippy -p ide-db -p openidea --all-targets -- -D warnings` is clean.
- `pnpm typecheck` and eslint pass. `pnpm vitest run src/features/database src/ui`: 226 passed.
- Checked in the running app against a SQLite database:
  - tree with triggers
  - result grid, local filter, side panel
  - chart
  - Explain Plan
  - history dialog
  - Export Data dialog
  - table editor ORDER BY
  - CSV import




Merge follow-up: grouped the new `--chart-series` token under a section comment and added it to the theme plugin template (theme.test.ts on main), regenerated docs/plugins/themes.md. Re-verified after merging main on macOS with a SQLite source: query + result grid with filter/sort headers, Explain Plan tab (Full Scan / Index Scan / Group By / Order By), Query History dialog; `cargo nextest run -p ide-db` 45 passed, bindings unchanged after regeneration.

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 13:31:29 +08:00

216 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { MenuItem } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { fileSourceName, parseSourceUrl } from "./model";
import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
const act = (
action: string,
node: DbNode | null,
extra: Partial<{ label: string; disabled: boolean; icon: string }> = {},
): MenuItem => ({
kind: "action",
action,
arg: node ?? undefined,
...extra,
});
const sep: MenuItem = { kind: "separator" };
async function sqliteFromFile() {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
/** New ▸ Data Source ▸ — the drivers the backend speaks and wire-compatible products, A–Z like IDEA's. */
function dataSourceItems(): MenuItem[] {
return [
{
label: "Data Source",
icon: "database",
submenu: VENDORS.map((v) => ({
label: v.label,
icon: v.icon,
run: () => openSourceDialog(null, vendorPreset(v)),
})),
},
{ label: "Data Source from File…", icon: "folder", run: () => void sqliteFromFile() },
{ label: "Data Source from URL…", icon: "external-link", run: () => openDialog({ kind: "sourceUrl" }) },
{ label: "Import from Clipboard", icon: "paste", run: () => void sourceFromClipboard() },
];
}
/** New ▸ Import from Clipboard: a connection URL (JDBC or native) on the clipboard becomes a new data source. */
async function sourceFromClipboard() {
let text = "";
try {
text = (await navigator.clipboard?.readText()) ?? "";
} catch {
// Unreadable clipboard: reported below like an unknown one.
}
const parsed = text.trim() ? parseSourceUrl(text.trim()) : null;
if (parsed) openSourceDialog(null, parsed.source, parsed.password);
else
notifications().notify({
level: "info",
title: "No data source on the clipboard",
body: "Copy a connection URL such as postgresql://user@host:5432/db or jdbc:sqlite:/path/app.db.",
key: "db-clipboard",
});
}
/** The toolbar's "+" menu (IDEA: New). */
export function newMenu(): MenuItem[] {
return [{ kind: "action", action: "db.openConsole", label: "Query Console" }, sep, ...dataSourceItems()];
}
function sqlScripts(n: DbNode, table: boolean): MenuItem {
const scripts: MenuItem[] = table
? (Object.keys(SCRIPT_LABELS) as ScriptKind[]).map((kind) => ({
kind: "action",
action: "db.sqlScript",
arg: { node: n, kind },
label: SCRIPT_LABELS[kind],
}))
: [act("db.runSqlScript", n)];
return { label: "SQL Scripts", submenu: [act("db.ddlToClipboard", n), act("db.ddlToConsole", n), sep, ...scripts] };
}
/** Context menu of a tree node (`null`: the empty area). */
export function nodeMenu(n: DbNode | null): MenuItem[] {
if (!n) return [{ label: "New", icon: "plus", submenu: newMenu() }];
const id = nodeTarget(n) ?? "";
const driver = sourceById(id)?.driver ?? "sqlite";
const o = objectOf(n);
const t = tableOf(n);
const newConsole = act("db.openConsole", n, { label: "Query Console", icon: "db-console" });
const refresh = act("db.refresh", n);
const quickDoc = act("db.quickDoc", n);
const rename = act("db.rename", n, { disabled: !(n.kind === "source" || (o && canRename(driver, o))) });
const drop = act("db.drop", n, { disabled: !o || !canDrop(driver, o) });
const copyRef = act("db.copyReference", n);
const navigation = (extra: MenuItem[] = []): MenuItem => ({
label: "Navigation",
submenu: [...extra, act("db.jumpToConsole", n)],
});
switch (n.kind) {
case "source": {
const connected = connectedTargets(n.source.id ?? "").length > 0;
return [
act("db.properties", n, { label: "Properties" }),
sep,
{ label: "New", icon: "plus", submenu: [newConsole, sep, ...dataSourceItems()] },
{ label: "Copy/Paste", submenu: [copyRef, act("db.duplicateSource", n)] },
refresh,
act("db.disconnect", n, { disabled: !connected }),
sep,
rename,
sep,
quickDoc,
navigation(),
sep,
sqlScripts(n, false),
sep,
act("db.drop", n, { label: "Remove Data Source…" }),
];
}
case "database":
return [
{ label: "New", icon: "plus", submenu: [newConsole] },
{ label: "Copy/Paste", submenu: [copyRef] },
refresh,
act("db.disconnect", n, { disabled: !useDatabase.getState().connected.has(id) || n.current }),
sep,
navigation(),
sqlScripts(n, false),
];
case "schema":
case "folder":
return [
{ label: "New", icon: "plus", submenu: [newConsole, sep, act("db.newTable", n)] },
...(n.kind === "schema" ? [{ label: "Copy/Paste", submenu: [copyRef] }] : []),
refresh,
sep,
...(n.kind === "schema" ? [quickDoc] : []),
navigation(),
sep,
sqlScripts(n, false),
...(n.kind === "schema" ? [sep, drop] : []),
];
case "table":
case "column": {
const view = !!t?.view;
const column = n.kind === "column";
return [
{
label: "New",
icon: "plus",
submenu: [newConsole, sep, act("db.newColumn", n, { disabled: view }), act("db.newTable", n)],
},
act("db.modifyTable", n, { label: column ? "Modify Column…" : "Modify Table…", disabled: view }),
{ label: "Copy/Paste", submenu: [copyRef] },
act("db.editData", n),
refresh,
sep,
rename,
sep,
quickDoc,
navigation([act("db.goToDdl", n)]),
sep,
sqlScripts(n, true),
...(n.kind === "table" && !view
? [
{ label: "Import/Export", submenu: [act("db.importData", n)] },
{ label: "Object Actions", submenu: [act("db.truncate", n)] },
]
: []),
sep,
drop,
];
}
case "index":
return [
{ label: "Copy/Paste", submenu: [copyRef] },
refresh,
sep,
rename,
sep,
navigation([act("db.goToDdl", n)]),
sep,
drop,
];
case "foreignKey":
return [refresh, sep, { label: "Navigation", submenu: [act("db.goToReferencedTable", n), act("db.goToDdl", n)] }];
case "routine":
case "sequence":
case "trigger":
return [
{ label: "Copy/Paste", submenu: [copyRef] },
refresh,
sep,
quickDoc,
navigation([act("db.goToDdl", n)]),
{ label: "SQL Scripts", submenu: [act("db.ddlToClipboard", n), act("db.ddlToConsole", n)] },
sep,
drop,
];
case "group":
case "key":
return [
...(n.kind === "group" && n.group === "columns"
? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n)] }]
: []),
refresh,
sep,
{ label: "Navigation", submenu: [act("db.goToDdl", n)] },
];
default:
return [];
}
}