Файли
openidea/src/features/database/index.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

190 рядки
6.5 KiB
TypeScript

/**
* Database tools (IDEA's Database tool window, scoped): data sources for
* SQLite / PostgreSQL / MySQL-MariaDB, a schema tree, query consoles with
* results grids, and a table data editor. Connections, introspection and
* statements run in Rust (`ide-db`).
*/
import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import {
registerEditorKind,
registerOverlay,
registerServiceView,
registerToolWindow,
setFileDecorations,
toolWindowService,
} from "@/workbench/contributions";
import { SQL_LANGUAGE, consoleExtension, executeInView, explainInView } from "./console";
import { DatabaseDialogs } from "./Dialogs";
import { TABLE_KIND, tableTitle } from "./tableInput";
import { nodeTarget, type DbNode } from "./nodes";
import { registerDatabaseTreeActions } from "./treeActions";
import {
DB_TOOL_WINDOW,
RESULTS_VIEW,
consoleSource,
consoleTints,
loadSources,
openConsole,
openHistory,
openSourceDialog,
useDatabase,
watchIdleConnections,
} from "./store";
export function activate(): () => void {
const disposers = [
registerToolWindow({
id: DB_TOOL_WINDOW,
title: "Database",
icon: "toolwindow-database",
anchor: "right",
order: 20,
component: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseView)),
headerActions: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseHeaderActions)),
}),
registerServiceView({
id: RESULTS_VIEW,
title: "Database",
icon: "database",
order: 20,
onUse: true,
component: lazyComponent(() => import("./ResultsView").then((m) => m.ResultsView)),
}),
registerEditorKind({
kind: TABLE_KIND,
component: lazyComponent(() => import("./TableEditor").then((m) => m.TableEditor)),
title: tableTitle,
icon: () => "database",
}),
registerOverlay({ id: "database.dialogs", component: DatabaseDialogs }),
registerEditorContribution({ id: "database.console", order: 57, extensions: consoleExtension }),
// A reopened project starts with a fresh backend: forget the old one's state.
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root) return;
useDatabase.setState({
sources: [],
loaded: false,
connected: new Set(),
databases: {},
schemas: {},
tables: {},
details: {},
objects: {},
completion: {},
results: [],
output: [],
activeResult: null,
selectedSource: null,
selectedNode: null,
treeExpanded: new Set(),
treeReveal: null,
manualCommit: new Set(),
pendingTx: new Set(),
running: {},
dialog: null,
newMenuOpen: false,
});
}),
// Consoles of coloured data sources get a tinted tab.
useDatabase.subscribe((s, prev) => {
if (s.consoles !== prev.consoles || s.sources !== prev.sources) setFileDecorations("database", consoleTints(s));
}),
() => setFileDecorations("database", new Map()),
watchIdleConnections(),
registerDatabaseTreeActions(),
registerActions([
{
id: "db.showToolWindow",
title: "Database",
category: "Tools",
icon: "toolwindow-database",
keywords: ["data sources", "sql", "tables"],
when: "projectOpen",
run: () => toolWindowService().show(DB_TOOL_WINDOW, true),
},
{
id: "db.newDataSource",
title: "New Data Source…",
category: "Tools",
icon: "plus",
keywords: ["database", "connection", "postgres", "mysql", "sqlite"],
when: "projectOpen",
run: async () => {
if (!useDatabase.getState().loaded) await loadSources();
openSourceDialog(null);
},
},
{
id: "db.openConsole",
title: "Query Console",
category: "Tools",
icon: "file-sql",
keywords: ["database", "sql console"],
when: "projectOpen",
run: async ({ arg }) => {
const s = useDatabase.getState();
if (!s.loaded) await loadSources();
const { sources, selectedSource } = useDatabase.getState();
// A tree node (New ▸ Query Console) opens its data source's console.
const node = arg && typeof arg === "object" && "kind" in arg ? nodeTarget(arg as DbNode) : null;
const id = node ?? selectedSource ?? (sources.length === 1 ? sources[0]!.id : null);
if (id) await openConsole(id);
else toolWindowService().show(DB_TOOL_WINDOW, true);
},
},
{
id: "db.execute",
title: "Execute SQL Statement",
category: "Tools",
icon: "run",
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
openSourceDialog(null);
return;
}
await executeInView(e.view, e.ctx.path);
},
},
{
id: "db.explainPlan",
title: "Explain Plan",
category: "Tools",
icon: "db-plan",
keywords: ["query plan", "explain", "sql"],
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
await explainInView(e.view, e.ctx.path);
},
},
{
id: "db.queryHistory",
title: "Browse Query History",
category: "Tools",
icon: "history",
keywords: ["database", "sql", "history"],
when: "projectOpen",
run: async () => {
if (!useDatabase.getState().loaded) await loadSources();
const e = activeEditor();
const path = e?.ctx.language === SQL_LANGUAGE ? e.ctx.path : null;
const s = useDatabase.getState();
const id =
(path && consoleSource(path)) ?? s.selectedSource ?? (s.sources.length === 1 ? s.sources[0]!.id : null);
openHistory(path, id ?? null);
},
},
]),
];
return () => disposers.reverse().forEach((d) => d());
}