Файли
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 рядки
7.3 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/** 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 [];
}
}