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

199 рядки
6.3 KiB
TypeScript

/** Data source (add / edit / test connection) and password dialogs. */
import { Fragment, useState } from "react";
import { call, commands, type DataSource } from "@/platform/ipc";
import { Checkbox, Dialog, Input, lazyComponent, T } from "@/ui";
import { sshLabel } from "./model";
import { SourceDialog } from "./SourceDialog";
import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const ExportDialog = lazyComponent(() => import("./ExportDialog").then((m) => m.ExportDialog));
const HistoryDialog = lazyComponent(() => import("./DataDialogs").then((m) => m.HistoryDialog));
const ImportDialog = lazyComponent(() => import("./DataDialogs").then((m) => m.ImportDialog));
function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok: boolean) => void }) {
const [password, setPassword] = useState("");
const [save, setSave] = useState(!!source.savePassword);
const done = (ok: boolean) => {
closeDialog();
resolve(ok);
};
const submit = async () => {
await call(commands.dbSetPassword(source.id ?? "", password, save));
done(true);
};
return (
<Dialog
open
onClose={() => done(false)}
title={`Connect to ${source.name}`}
width={400}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Connect", role: "default", onClick: () => void submit() },
]}
>
<div className={styles.form}>
<span className={styles.formLabel}>
<T>User:</T>
</span>
<span>{source.user || "(default)"}</span>
<label className={styles.formLabel} htmlFor="db-ask-password">
<T>Password:</T>
</label>
<Input
id="db-ask-password"
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<span />
<Checkbox checked={save} onChange={setSave} label="Save password" />
</div>
</Dialog>
);
}
/** SSH password / key passphrase asked on connect. */
function SshSecretDialog({
source,
prompt,
resolve,
}: {
source: DataSource;
prompt: string;
resolve: (ok: boolean) => void;
}) {
const [secret, setSecret] = useState("");
const [save, setSave] = useState(!!source.ssh?.saveSecret);
const password = source.ssh?.auth === "password" || /password required/.test(prompt);
const done = (ok: boolean) => {
closeDialog();
resolve(ok);
};
const submit = async () => {
await call(commands.dbSetSshSecret(source.id ?? "", secret, save));
done(true);
};
return (
<Dialog
open
onClose={() => done(false)}
title={`SSH: ${sshLabel(source) || source.name}`}
width={440}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Connect", role: "default", onClick: () => void submit() },
]}
>
<div className={styles.form}>
<span />
<span className={styles.formHint}>{prompt}</span>
<label className={styles.formLabel} htmlFor="db-ask-ssh-secret">
{password ? "Password:" : "Passphrase:"}
</label>
<Input
id="db-ask-ssh-secret"
type="password"
autoFocus
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<span />
<Checkbox checked={save} onChange={setSave} label="Save password" />
</div>
</Dialog>
);
}
/** IDEA's parameter dialog: one SQL value per query parameter, remembered for the next run. */
function ParamsDialog({
names,
resolve,
}: {
names: string[];
resolve: (values: Record<string, string> | null) => void;
}) {
const [values, setValues] = useState<Record<string, string>>(() => {
const last = useDatabase.getState().paramValues;
return Object.fromEntries(names.map((n) => [n, last[n] ?? ""]));
});
const done = (v: Record<string, string> | null) => {
closeDialog();
resolve(v);
};
return (
<Dialog
open
onClose={() => done(null)}
title="Parameters"
width={480}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "Execute", role: "default", onClick: () => done(values) },
]}
>
<div
className={styles.form}
onKeyDown={(e) => {
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
e.preventDefault();
done(values);
}}
>
{names.map((n, i) => (
<Fragment key={n}>
<label className={styles.formLabel} htmlFor={`db-param-${i}`}>
{n}
</label>
<Input
id={`db-param-${i}`}
className={styles.paramInput}
autoFocus={i === 0}
value={values[n] ?? ""}
placeholder="NULL"
onChange={(e) => setValues((v) => ({ ...v, [n]: e.target.value }))}
/>
</Fragment>
))}
<span />
<span className={styles.formHint}>
<T>Values are inserted as SQL: quote strings, e.g. 'abc'.</T>
</span>
</div>
</Dialog>
);
}
/** Overlay host: whichever dialog the store asks for. */
export function DatabaseDialogs() {
const dialog = useDatabase((s) => s.dialog);
if (!dialog) return null;
if (dialog.kind === "source")
return (
<SourceDialog
key={dialog.source?.id ?? "new"}
initial={dialog.source}
preset={dialog.preset}
presetPassword={dialog.password}
/>
);
if (dialog.kind === "params") return <ParamsDialog names={dialog.names} resolve={dialog.resolve} />;
if (dialog.kind === "export")
return <ExportDialog data={dialog.data} source={dialog.source} initialFormat={dialog.format} />;
if (dialog.kind === "history") return <HistoryDialog key={dialog.id} id={dialog.id} path={dialog.path} />;
if (dialog.kind === "import")
return (
<ImportDialog key={`${dialog.id}.${dialog.table}`} id={dialog.id} schema={dialog.schema} table={dialog.table} />
);
if (dialog.kind === "sshSecret")
return (
<SshSecretDialog key={dialog.source.id} source={dialog.source} prompt={dialog.prompt} resolve={dialog.resolve} />
);
if (dialog.kind === "password")
return <PasswordDialog key={dialog.source.id} source={dialog.source} resolve={dialog.resolve} />;
return <ObjectDialogs />;
}