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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 13:31:29 +08:00
1 джерело 580f0efc28
коміт 575749f2ac
48 files changed
+4268 -289

No files matched your search

+73 -8
Переглянути файл
@@ -1,12 +1,17 @@
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { MenuItem } from "@/ui";
import { fileSourceName } from "./model";
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 => ({
const act = (
action: string,
node: DbNode | null,
extra: Partial<{ label: string; disabled: boolean; icon: string }> = {},
): MenuItem => ({
kind: "action",
action,
arg: node ?? undefined,
@@ -27,13 +32,37 @@ function dataSourceItems(): MenuItem[] {
{
label: "Data Source",
icon: "database",
submenu: VENDORS.map((v) => ({ label: v.label, icon: v.icon, run: () => openSourceDialog(null, vendorPreset(v)) })),
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()];
@@ -64,7 +93,10 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
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)] });
const navigation = (extra: MenuItem[] = []): MenuItem => ({
label: "Navigation",
submenu: [...extra, act("db.jumpToConsole", n)],
});
switch (n.kind) {
case "source": {
@@ -115,7 +147,11 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
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)] },
{
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),
@@ -127,19 +163,48 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
navigation([act("db.goToDdl", n)]),
sep,
sqlScripts(n, true),
...(n.kind === "table" && !view ? [{ label: "Object Actions", submenu: [act("db.truncate", n)] }] : []),
...(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];
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)] }] : []),
...(n.kind === "group" && n.group === "columns"
? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n)] }]
: []),
refresh,
sep,
{ label: "Navigation", submenu: [act("db.goToDdl", n)] },