дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
580f0efc28
коміт
575749f2ac
48 files changed
+4268
-289
No files matched your search
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* IDEA's result chart: one numeric column (Y) against a category column (X)
|
||||
* as bars or a line, with the Y values of equal X combined (sum, average…).
|
||||
*/
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { IconButton, Select, T } from "@/ui";
|
||||
import { isNumericColumn, type GridColumn } from "./Grid";
|
||||
import type { Cell } from "./model";
|
||||
import { chartSeries, formatNumber, niceTicks, type ChartAggregate } from "./resultView";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
const AGGREGATES: ReadonlyArray<{ value: ChartAggregate; label: string }> = [
|
||||
{ value: "none", label: "No aggregation" },
|
||||
{ value: "sum", label: "Sum" },
|
||||
{ value: "avg", label: "Mean" },
|
||||
{ value: "count", label: "Count" },
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
const PAD = { left: 64, right: 16, top: 12, bottom: 44 };
|
||||
|
||||
const grouped = new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 });
|
||||
|
||||
export function ChartView({ columns, rows }: { columns: readonly GridColumn[]; rows: ReadonlyArray<readonly Cell[]> }) {
|
||||
const numeric = useMemo(
|
||||
() =>
|
||||
columns.map((c, i) =>
|
||||
isNumericColumn(
|
||||
c.type,
|
||||
rows.slice(0, 50).map((r) => r[i] ?? null),
|
||||
),
|
||||
),
|
||||
[columns, rows],
|
||||
);
|
||||
const firstText = numeric.findIndex((n) => !n);
|
||||
const [x, setX] = useState(Math.max(0, firstText));
|
||||
const [y, setY] = useState(() => {
|
||||
const n = numeric.findLastIndex((isNum, i) => isNum && i !== Math.max(0, firstText));
|
||||
return n >= 0 ? n : Math.min(1, columns.length - 1);
|
||||
});
|
||||
const [kind, setKind] = useState<"bar" | "line">("bar");
|
||||
// IDEA combines equal X values with their mean unless told otherwise.
|
||||
const [aggregate, setAgg] = useState<ChartAggregate>("avg");
|
||||
const [settings, setSettings] = useState(false);
|
||||
const points = useMemo(() => chartSeries(rows, x, y, aggregate), [rows, x, y, aggregate]);
|
||||
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ w: 600, h: 300 });
|
||||
useLayoutEffect(() => {
|
||||
const el = host.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
|
||||
ro.observe(el);
|
||||
setSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const options = columns.map((c, i) => ({ value: String(i), label: c.name }));
|
||||
const values = points.map((p) => p.value);
|
||||
const ticks = niceTicks(
|
||||
Math.min(0, ...values),
|
||||
Math.max(0, ...values),
|
||||
Math.max(2, Math.floor((size.h - PAD.top - PAD.bottom) / 32)),
|
||||
);
|
||||
const lo = ticks[0] ?? 0;
|
||||
const hi = ticks.at(-1) ?? 1;
|
||||
const plotW = Math.max(1, size.w - PAD.left - PAD.right);
|
||||
const plotH = Math.max(1, size.h - PAD.top - PAD.bottom);
|
||||
const yAt = (v: number) => PAD.top + plotH - ((v - lo) / (hi - lo || 1)) * plotH;
|
||||
const band = plotW / Math.max(1, points.length);
|
||||
const xAt = (i: number) => PAD.left + band * i + band / 2;
|
||||
// Every n-th label so they do not overlap.
|
||||
const labelEvery = Math.max(1, Math.ceil(points.length / Math.max(1, Math.floor(plotW / 72))));
|
||||
const yName = `${columns[y]?.name ?? ""}${aggregate === "none" ? "" : ` (${AGGREGATES.find((a) => a.value === aggregate)!.label.toLowerCase()})`}`;
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
{settings && (
|
||||
<div className={styles.chartBar}>
|
||||
<span className={styles.formLabel}>
|
||||
<T>X:</T>
|
||||
</span>
|
||||
<Select aria-label="X axis" value={String(x)} options={options} onChange={(v) => setX(Number(v))} />
|
||||
<span className={styles.formLabel}>
|
||||
<T>Y:</T>
|
||||
</span>
|
||||
<Select aria-label="Y axis" value={String(y)} options={options} onChange={(v) => setY(Number(v))} />
|
||||
<Select
|
||||
aria-label="Aggregation"
|
||||
value={aggregate}
|
||||
options={AGGREGATES.map((a) => ({ ...a }))}
|
||||
onChange={(v) => setAgg(v as ChartAggregate)}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Chart type"
|
||||
value={kind}
|
||||
options={[
|
||||
{ value: "bar", label: "Bar" },
|
||||
{ value: "line", label: "Line" },
|
||||
]}
|
||||
onChange={(v) => setKind(v as "bar" | "line")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={host} className={styles.chartHost}>
|
||||
<IconButton
|
||||
className={styles.chartSettings}
|
||||
icon="settings"
|
||||
title="Chart Settings"
|
||||
aria-pressed={settings}
|
||||
onClick={() => setSettings(!settings)}
|
||||
/>
|
||||
{!points.length ? (
|
||||
<div className={styles.filterEmpty}>
|
||||
<T>No numeric values to plot</T>
|
||||
</div>
|
||||
) : (
|
||||
<svg width={size.w} height={size.h} role="img" aria-label={`${yName} by ${columns[x]?.name ?? ""}`}>
|
||||
{ticks.map((t) => (
|
||||
<g key={t}>
|
||||
<line className={styles.chartGrid} x1={PAD.left} x2={PAD.left + plotW} y1={yAt(t)} y2={yAt(t)} />
|
||||
<text
|
||||
className={styles.chartTick}
|
||||
x={PAD.left - 6}
|
||||
y={yAt(t)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{grouped.format(t)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{points.map((_, i) => (
|
||||
<line
|
||||
key={i}
|
||||
className={styles.chartGrid}
|
||||
x1={PAD.left + band * (i + 1)}
|
||||
x2={PAD.left + band * (i + 1)}
|
||||
y1={PAD.top}
|
||||
y2={PAD.top + plotH}
|
||||
/>
|
||||
))}
|
||||
{kind === "bar"
|
||||
? points.map((p, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
className={styles.chartBarMark}
|
||||
x={PAD.left + band * i + band * 0.05}
|
||||
width={band * 0.9}
|
||||
y={Math.min(yAt(p.value), yAt(0))}
|
||||
height={Math.abs(yAt(0) - yAt(p.value))}
|
||||
>
|
||||
<title>{`${p.label}: ${formatNumber(p.value)}`}</title>
|
||||
</rect>
|
||||
))
|
||||
: [
|
||||
<polyline
|
||||
key="line"
|
||||
className={styles.chartLine}
|
||||
points={points.map((p, i) => `${xAt(i)},${yAt(p.value)}`).join(" ")}
|
||||
/>,
|
||||
...points.map((p, i) => (
|
||||
<circle key={i} className={styles.chartDot} cx={xAt(i)} cy={yAt(p.value)} r={3}>
|
||||
<title>{`${p.label}: ${formatNumber(p.value)}`}</title>
|
||||
</circle>
|
||||
)),
|
||||
]}
|
||||
<line className={styles.chartAxis} x1={PAD.left} x2={PAD.left + plotW} y1={yAt(0)} y2={yAt(0)} />
|
||||
{points.map((p, i) =>
|
||||
i % labelEvery ? null : (
|
||||
<text key={i} className={styles.chartTick} x={xAt(i)} y={PAD.top + plotH + 14} textAnchor="middle">
|
||||
{p.label.length > 14 ? `${p.label.slice(0, 13)}…` : p.label}
|
||||
</text>
|
||||
),
|
||||
)}
|
||||
<text className={styles.chartAxisName} x={PAD.left + plotW / 2} y={size.h - 6} textAnchor="middle">
|
||||
{columns[x]?.name}
|
||||
</text>
|
||||
<text
|
||||
className={styles.chartAxisName}
|
||||
transform={`translate(12 ${PAD.top + plotH / 2}) rotate(-90)`}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{yName}
|
||||
</text>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Read-only text with line numbers (IDEA's preview editors), SQL in the editor's syntax colours. */
|
||||
import { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
export function CodePreview({
|
||||
text,
|
||||
sql = false,
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
text: string;
|
||||
sql?: boolean;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
if (sql) void highlightInto(el, text, "SQL");
|
||||
}, [text, sql]);
|
||||
const lines = text.split("\n").length;
|
||||
return (
|
||||
<div className={clsx(styles.codePreview, className)}>
|
||||
<div className={styles.codeGutter} aria-hidden>
|
||||
{Array.from({ length: lines }, (_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<pre ref={ref} className={styles.codeText} aria-label={ariaLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/** Query History (browse / paste / delete) and Import Data from File dialogs. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { call, commands, type DbHistoryEntry } from "@/platform/ipc";
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { Checkbox, Dialog, EmptyState, IconButton, Input, List, Select, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { CodePreview } from "./CodePreview";
|
||||
import { guessSeparator, importEdits, parseDelimited } from "./model";
|
||||
import { closeDialog, detailsKey, loadDetails, openConsoleWith, targetLabel, useDatabase } from "./store";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
function when(t: number): string {
|
||||
const d = new Date(t);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Pastes `text` over the selection of console `path` when it is open, else appends it to the data source's console. */
|
||||
async function paste(id: string, path: string | null, text: string) {
|
||||
const view = path ? editorsFor(path)[0]?.view : undefined;
|
||||
if (!view) {
|
||||
await openConsoleWith(id, text);
|
||||
return;
|
||||
}
|
||||
const sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: text },
|
||||
selection: { anchor: sel.from, head: sel.from + text.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
/** IDEA's `<data source> Query History`: statements newest first, a preview, Paste into the console. */
|
||||
export function HistoryDialog({ id, path }: { id: string; path: string | null }) {
|
||||
const [entries, setEntries] = useState<DbHistoryEntry[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
useEffect(() => {
|
||||
void call(commands.dbHistory(id))
|
||||
.then(setEntries)
|
||||
.catch(() => setEntries([]));
|
||||
}, [id]);
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (entries ?? []).filter((e) => !q || e.text.toLowerCase().includes(q));
|
||||
}, [entries, query]);
|
||||
const current = shown[Math.min(active, shown.length - 1)];
|
||||
|
||||
const remove = async (texts: string[] | null) => {
|
||||
await call(commands.dbRemoveHistory(id, texts));
|
||||
setEntries((e) => (texts ? (e ?? []).filter((x) => !texts.includes(x.text)) : []));
|
||||
};
|
||||
const done = (text: string | undefined) => {
|
||||
closeDialog();
|
||||
if (text) void paste(id, path, text);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={closeDialog}
|
||||
title={`${targetLabel(id)} Query History`}
|
||||
width={820}
|
||||
footerStart={
|
||||
<IconButton icon="trash" title="Clear History" disabled={!entries?.length} onClick={() => void remove(null)} />
|
||||
}
|
||||
buttons={[
|
||||
{ label: "Close", role: "cancel" },
|
||||
{ label: "Paste", role: "default", disabled: !current, onClick: () => done(current?.text) },
|
||||
]}
|
||||
>
|
||||
<div className={styles.historyDialog}>
|
||||
<div className={styles.historyList}>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Search history"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, Math.min(shown.length - 1, a + (e.key === "ArrowDown" ? 1 : -1))));
|
||||
} else if (e.key === "Enter") done(current?.text);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="minus"
|
||||
title="Delete"
|
||||
disabled={!current}
|
||||
onClick={() => current && void remove([current.text])}
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
className={styles.fill}
|
||||
aria-label="Statements"
|
||||
items={shown}
|
||||
getKey={(e) => e.text}
|
||||
activeIndex={Math.min(active, shown.length - 1)}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(e) => done(e.text)}
|
||||
renderItem={(e) => (
|
||||
<span className={styles.historyItem} title={when(e.time ?? 0)}>
|
||||
<span className={styles.historyText}>{e.text.replace(/\s+/g, " ")}</span>
|
||||
</span>
|
||||
)}
|
||||
empty={<EmptyState title={entries === null ? "Loading…" : "No statements"} />}
|
||||
/>
|
||||
</div>
|
||||
<CodePreview sql className={styles.historyPreview} aria-label="Statement" text={current?.text ?? ""} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const SEPARATORS = [
|
||||
{ value: ",", label: "Comma" },
|
||||
{ value: "\t", label: "Tab" },
|
||||
{ value: ";", label: "Semicolon" },
|
||||
{ value: "|", label: "Pipe" },
|
||||
];
|
||||
|
||||
/** Import Data from File: a CSV / TSV file inserted into a table in one transaction. */
|
||||
export function ImportDialog({ id, schema, table }: { id: string; schema: string; table: string }) {
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [sep, setSep] = useState(",");
|
||||
const [header, setHeader] = useState(true);
|
||||
const [emptyAsNull, setEmptyAsNull] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const details = useDatabase((s) => s.details[detailsKey(id, schema, table)]);
|
||||
useEffect(() => {
|
||||
if (details?.status !== "ok") void loadDetails(id, schema, table);
|
||||
// Once per table.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, schema, table]);
|
||||
const columns = useMemo(() => (details?.status === "ok" ? details.value.columns.map((c) => c.name) : []), [details]);
|
||||
const rows = useMemo(() => (text ? parseDelimited(text, sep) : []), [text, sep]);
|
||||
const { edits, mapped } = useMemo(
|
||||
() => importEdits(rows, columns, header, emptyAsNull),
|
||||
[rows, columns, header, emptyAsNull],
|
||||
);
|
||||
|
||||
const choose = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const f = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
title: "Import Data from File",
|
||||
filters: [{ name: "CSV / TSV", extensions: ["csv", "tsv", "txt"] }],
|
||||
});
|
||||
if (typeof f !== "string") return;
|
||||
const content = await call(commands.fsReadText(f));
|
||||
setFile(f);
|
||||
setText(content);
|
||||
setSep(guessSeparator(content));
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot read the file",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const n = await call(commands.dbApplyEdits(id, schema, table, edits));
|
||||
notifications().notify({ level: "info", title: `${table}: ${n} row(s) imported`, key: "db-import" });
|
||||
closeDialog();
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Nothing was imported",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "db-import",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = rows.slice(0, 8);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={closeDialog}
|
||||
title={`Import Data into ${schema}.${table}`}
|
||||
width={720}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{
|
||||
label: `Import ${edits.length} Rows`,
|
||||
role: "default",
|
||||
disabled: !edits.length || !mapped.length || busy,
|
||||
onClick: () => void run(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<span className={styles.formLabel}>
|
||||
<T>File:</T>
|
||||
</span>
|
||||
<div className={styles.formRow}>
|
||||
<Input readOnly aria-label="File" value={file ?? ""} placeholder="Choose a CSV or TSV file" />
|
||||
<IconButton icon="folder" title="Choose File…" onClick={() => void choose()} />
|
||||
</div>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Separator:</T>
|
||||
</span>
|
||||
<Select aria-label="Separator" value={sep} options={SEPARATORS} onChange={setSep} />
|
||||
<span />
|
||||
<Checkbox checked={header} onChange={setHeader} label="First row is header (columns matched by name)" />
|
||||
<span />
|
||||
<Checkbox checked={emptyAsNull} onChange={setEmptyAsNull} label="Treat empty values as NULL" />
|
||||
<span className={styles.formLabel}>
|
||||
<T>Columns:</T>
|
||||
</span>
|
||||
<span className={styles.formHint}>
|
||||
{mapped.length ? mapped.join(", ") : "No column of the file matches the table"}
|
||||
</span>
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className={styles.importPreview}>
|
||||
<table>
|
||||
<tbody>
|
||||
{preview.map((r, i) => (
|
||||
<tr key={i} className={header && i === 0 ? styles.importHeader : undefined}>
|
||||
{r.map((v, j) => (
|
||||
<td key={j}>{v}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* A data grid with IDEA's result-view extras, shared by console results and
|
||||
* the table editor: the header's local filter (values of the loaded rows),
|
||||
* client-side sorting when the caller does not sort on the server, and the
|
||||
* Record / Value / Aggregates side panel following the selection.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Popup, Select, Tabs, T, type MenuItem } from "@/ui";
|
||||
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
|
||||
import { Grid, type GridColumn, type GridProps, type GridRange } from "./Grid";
|
||||
import type { Cell } from "./model";
|
||||
import {
|
||||
aggregates,
|
||||
distinctValues,
|
||||
formatNumber,
|
||||
toggleFilterValue,
|
||||
visibleRows,
|
||||
type LocalFilter,
|
||||
type LocalSort,
|
||||
} from "./resultView";
|
||||
import { openDialog } from "./store";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
export type DataPanel = "record" | "value" | "aggregates";
|
||||
|
||||
/** The Export button's menu: the Export Data dialog (with the toolbar's extractor), then IDEA's quick Copy as… extractors. */
|
||||
export function exportMenu(data: ExportData, source: string, format?: ExportFormat): MenuItem[] {
|
||||
return [
|
||||
{ label: "Export Data…", icon: "export", run: () => openDialog({ kind: "export", data, source, format }) },
|
||||
{ kind: "separator" },
|
||||
...EXPORT_FORMATS.map((f): MenuItem => ({
|
||||
label: `Copy as ${f.label}`,
|
||||
icon: "copy",
|
||||
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/** IDEA's extractor drop-down at the right of a data toolbar ("CSV ▾"). */
|
||||
export function ExtractorSelect({ value, onChange }: { value: ExportFormat; onChange: (f: ExportFormat) => void }) {
|
||||
return (
|
||||
<Select
|
||||
aria-label="Data extractor"
|
||||
value={value}
|
||||
options={EXPORT_FORMATS.map((f) => ({ value: f.id, label: f.label }))}
|
||||
onChange={(v) => onChange(v as ExportFormat)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Record / Value / Aggregates panel's toggle (IDEA's "Show Value Editor"). */
|
||||
export function PanelToggle({ panel, onChange }: { panel: DataPanel | null; onChange: (p: DataPanel | null) => void }) {
|
||||
return (
|
||||
<IconButton
|
||||
icon="split"
|
||||
title={panel ? "Hide Record / Value / Aggregates" : "Show Record / Value / Aggregates"}
|
||||
aria-pressed={!!panel}
|
||||
onClick={() => onChange(panel ? null : "record")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Local filter and sort of the column set `key`. */
|
||||
interface Shaping {
|
||||
key: string;
|
||||
filter: LocalFilter;
|
||||
sort: LocalSort | null;
|
||||
}
|
||||
|
||||
export interface DataViewProps extends Pick<
|
||||
GridProps,
|
||||
"rowState" | "isModified" | "editable" | "onEdit" | "sort" | "onSort" | "rowMenu"
|
||||
> {
|
||||
columns: readonly GridColumn[];
|
||||
rowCount: number;
|
||||
value: (row: number, col: number) => Cell;
|
||||
/** Rows from here on (the table editor's added rows) are never filtered or sorted. */
|
||||
fixedRows?: number;
|
||||
firstRowNumber?: number;
|
||||
/** The side panel shown (`null`: hidden). */
|
||||
panel: DataPanel | null;
|
||||
onPanelChange: (panel: DataPanel | null) => void;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function DataView(props: DataViewProps) {
|
||||
const { columns, rowCount, value, fixedRows = rowCount, panel, onPanelChange } = props;
|
||||
const columnKey = columns.map((c) => c.name).join("\u0000");
|
||||
// Filter and sort belong to a column set: another one (a re-run query) starts without them.
|
||||
const [shaping, setShaping] = useState<Shaping>({ key: columnKey, filter: new Map(), sort: null });
|
||||
const current: Shaping = shaping.key === columnKey ? shaping : { key: columnKey, filter: new Map(), sort: null };
|
||||
const { filter, sort: localSort } = current;
|
||||
const setFilter = (f: LocalFilter | ((prev: LocalFilter) => LocalFilter)) =>
|
||||
setShaping({ ...current, filter: typeof f === "function" ? f(current.filter) : f });
|
||||
const setLocalSort = (s: (prev: LocalSort | null) => LocalSort | null) =>
|
||||
setShaping({ ...current, sort: s(current.sort) });
|
||||
const [range, setRange] = useState<GridRange | null>(null);
|
||||
const [filterAt, setFilterAt] = useState<{ col: number; anchor: HTMLElement } | null>(null);
|
||||
|
||||
const local = !props.onSort;
|
||||
// Display row → loaded row; `null` keeps the identity (no filter, no local sort).
|
||||
const order = useMemo(
|
||||
() =>
|
||||
filter.size || (local && localSort)
|
||||
? visibleRows(rowCount, value, filter, local ? localSort : null, fixedRows)
|
||||
: null,
|
||||
[rowCount, value, filter, local, localSort, fixedRows],
|
||||
);
|
||||
const at = (r: number) => (order ? order[r]! : r);
|
||||
const shown = order ? order.length : rowCount;
|
||||
|
||||
const onSort = local
|
||||
? (column: string) => {
|
||||
const col = columns.findIndex((c) => c.name === column);
|
||||
// asc → desc → none, like the table editor's.
|
||||
setLocalSort((s) =>
|
||||
s?.column !== col ? { column: col, descending: false } : !s.descending ? { ...s, descending: true } : null,
|
||||
);
|
||||
}
|
||||
: props.onSort;
|
||||
const sort = local
|
||||
? localSort
|
||||
? [{ column: columns[localSort.column]?.name ?? "", descending: localSort.descending }]
|
||||
: []
|
||||
: props.sort;
|
||||
|
||||
return (
|
||||
<div className={styles.dataView}>
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<Grid
|
||||
aria-label={props["aria-label"]}
|
||||
columns={columns}
|
||||
rowCount={shown}
|
||||
value={(r, c) => value(at(r), c)}
|
||||
rowState={props.rowState && ((r) => props.rowState!(at(r)))}
|
||||
isModified={props.isModified && ((r, c) => props.isModified!(at(r), c))}
|
||||
editable={props.editable}
|
||||
onEdit={props.onEdit && ((r, c, v) => props.onEdit!(at(r), c, v))}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))}
|
||||
rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)}
|
||||
onSelectionChange={setRange}
|
||||
onFilter={(col, anchor) => setFilterAt({ col, anchor })}
|
||||
filtered={(col) => filter.has(col)}
|
||||
/>
|
||||
{filter.size > 0 && (
|
||||
<div className={styles.filterPill} role="status">
|
||||
{shown} of {rowCount} rows
|
||||
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
|
||||
<T>Clear local filter</T>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filterAt && (
|
||||
<FilterPopup
|
||||
anchor={filterAt.anchor}
|
||||
column={columns[filterAt.col]?.name ?? ""}
|
||||
values={distinctValues(Math.min(rowCount, fixedRows), value, filterAt.col)}
|
||||
checked={filter.get(filterAt.col) ?? null}
|
||||
onToggle={(all, v, on) => setFilter((f) => toggleFilterValue(f, filterAt.col, all, v, on))}
|
||||
onClear={() =>
|
||||
setFilter((f) => {
|
||||
const next = new Map(f);
|
||||
next.delete(filterAt.col);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onClearAll={() => setFilter(new Map())}
|
||||
onClose={() => setFilterAt(null)}
|
||||
/>
|
||||
)}
|
||||
{panel && (
|
||||
<SidePanel
|
||||
panel={panel}
|
||||
onPanelChange={onPanelChange}
|
||||
columns={columns}
|
||||
range={range}
|
||||
value={(r, c) => value(at(r), c)}
|
||||
editable={!!props.editable && !!props.onEdit && !!range && props.rowState?.(at(range.lead.row)) !== "deleted"}
|
||||
onEdit={(r, c, v) => props.onEdit?.(at(r), c, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPopup(props: {
|
||||
anchor: HTMLElement;
|
||||
column: string;
|
||||
values: Array<{ value: Cell; count: number }>;
|
||||
checked: ReadonlySet<Cell> | null;
|
||||
onToggle: (all: Cell[], v: Cell, on: boolean) => void;
|
||||
onClear: () => void;
|
||||
onClearAll: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const all = props.values.map((v) => v.value);
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q ? props.values.filter((v) => (v.value ?? "<null>").toLowerCase().includes(q)) : props.values;
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement="bottom-start"
|
||||
onClose={props.onClose}
|
||||
aria-label={`Local filter of ${props.column}`}
|
||||
className={styles.filterPopup}
|
||||
trapFocus
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="Search values"
|
||||
placeholder={`Values of ${props.column}`}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className={styles.filterValues} role="group" aria-label="Values">
|
||||
{list.map(({ value: v, count }) => (
|
||||
<div key={v ?? "\u0000null"} className={styles.filterValue}>
|
||||
<Checkbox
|
||||
checked={props.checked ? props.checked.has(v) : true}
|
||||
onChange={(on) => props.onToggle(all, v, on)}
|
||||
label={v === null ? <span className={styles.null}><null></span> : v}
|
||||
/>
|
||||
<span className={styles.filterCount}>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
{!list.length && (
|
||||
<div className={styles.filterEmpty}>
|
||||
<T>No matching values</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.filterButtons}>
|
||||
<Button size="small" disabled={!props.checked} onClick={props.onClear}>
|
||||
<T>Clear Filter</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={props.onClearAll}>
|
||||
<T>Clear Local Filter For All Columns</T>
|
||||
</Button>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
const AGGREGATE_ROWS: ReadonlyArray<[label: string, key: keyof ReturnType<typeof aggregates>]> = [
|
||||
["Count", "count"],
|
||||
["Distinct", "distinct"],
|
||||
["Nulls", "nulls"],
|
||||
["Sum", "sum"],
|
||||
["Avg", "avg"],
|
||||
["Median", "median"],
|
||||
["Min", "min"],
|
||||
["Max", "max"],
|
||||
];
|
||||
|
||||
function SidePanel(props: {
|
||||
panel: DataPanel;
|
||||
onPanelChange: (p: DataPanel | null) => void;
|
||||
columns: readonly GridColumn[];
|
||||
range: GridRange | null;
|
||||
value: (row: number, col: number) => Cell;
|
||||
editable: boolean;
|
||||
onEdit: (row: number, col: number, v: Cell) => void;
|
||||
}) {
|
||||
const { range, value, columns } = props;
|
||||
return (
|
||||
<aside className={styles.sidePanel} aria-label="Value panel">
|
||||
<div className={styles.sidePanelHead}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
aria-label="Value panel"
|
||||
activeId={props.panel}
|
||||
onSelect={(id) => props.onPanelChange(id as DataPanel)}
|
||||
items={[
|
||||
{ id: "record", label: "Record" },
|
||||
{ id: "value", label: "Value" },
|
||||
{ id: "aggregates", label: "Aggregates" },
|
||||
]}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton icon="close" title="Hide" size="small" onClick={() => props.onPanelChange(null)} />
|
||||
</div>
|
||||
<div className={styles.sidePanelBody}>
|
||||
{!range ? (
|
||||
<div className={styles.filterEmpty}>
|
||||
<T>Select a cell</T>
|
||||
</div>
|
||||
) : props.panel === "record" ? (
|
||||
<RecordView key={range.lead.row} {...props} row={range.lead.row} />
|
||||
) : props.panel === "value" ? (
|
||||
<ValueView
|
||||
key={`${range.lead.row}:${range.lead.col}`}
|
||||
column={columns[range.lead.col]?.name ?? ""}
|
||||
cell={value(range.lead.row, range.lead.col)}
|
||||
editable={props.editable}
|
||||
onCommit={(v) => props.onEdit(range.lead.row, range.lead.col, v)}
|
||||
/>
|
||||
) : (
|
||||
<AggregatesView range={range} value={value} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** One field per column of the lead row; edits apply on Enter / leaving the field. */
|
||||
function RecordView(props: {
|
||||
columns: readonly GridColumn[];
|
||||
row: number;
|
||||
value: (row: number, col: number) => Cell;
|
||||
editable: boolean;
|
||||
onEdit: (row: number, col: number, v: Cell) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.recordFields}>
|
||||
{props.columns.map((c, i) => (
|
||||
<RecordField
|
||||
// A new value (another row, an edit elsewhere) starts the field over.
|
||||
key={`${c.name}\u0000${props.value(props.row, i) ?? "\u0001"}`}
|
||||
column={c}
|
||||
cell={props.value(props.row, i)}
|
||||
editable={props.editable}
|
||||
onCommit={(v) => props.onEdit(props.row, i, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordField(props: { column: GridColumn; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) {
|
||||
const [text, setText] = useState(props.cell ?? "");
|
||||
const commit = () => {
|
||||
if (text !== (props.cell ?? "") || (props.cell === null && text !== "")) props.onCommit(text);
|
||||
};
|
||||
const c = props.column;
|
||||
return (
|
||||
<label className={styles.recordField}>
|
||||
<span className={styles.recordLabel}>
|
||||
<Icon name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"} size={16} />
|
||||
{c.name}
|
||||
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
placeholder={props.cell === null ? "<null>" : undefined}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueView(props: { column: string; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) {
|
||||
const [text, setText] = useState(props.cell ?? "");
|
||||
const dirty = text !== (props.cell ?? "") || (props.cell === null && text !== "");
|
||||
return (
|
||||
<div className={styles.valueView}>
|
||||
<textarea
|
||||
className={styles.valueText}
|
||||
aria-label={`Value of ${props.column}`}
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
placeholder={props.cell === null ? "<null>" : undefined}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && dirty) props.onCommit(text);
|
||||
}}
|
||||
/>
|
||||
{props.editable && (
|
||||
<div className={styles.filterButtons}>
|
||||
<Button size="small" disabled={!dirty} onClick={() => props.onCommit(text)}>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => props.onCommit(null)}>
|
||||
<T>Set NULL</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AggregatesView({ range, value }: { range: GridRange; value: (row: number, col: number) => Cell }) {
|
||||
const cells: Cell[] = [];
|
||||
for (let r = range.top; r <= range.bottom; r++)
|
||||
for (let c = range.left; c <= range.right; c++) cells.push(value(r, c));
|
||||
const a = aggregates(cells);
|
||||
const show = (v: number | string | null) => (v === null ? "—" : typeof v === "number" ? formatNumber(v) : v);
|
||||
return (
|
||||
<table className={styles.aggregates}>
|
||||
<tbody>
|
||||
{AGGREGATE_ROWS.map(([label, key]) => (
|
||||
<tr key={key}>
|
||||
<th scope="row">{label}</th>
|
||||
<td>{show(a[key])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -141,9 +141,15 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Gives way before the name does. */
|
||||
.treeDetail {
|
||||
color: var(--fg-muted);
|
||||
margin-left: 6px;
|
||||
min-width: 0;
|
||||
flex-shrink: 100;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* IDEA: counts after folders, column types in a smaller grey face, key rows in grey. */
|
||||
.treeCount {
|
||||
@@ -291,9 +297,28 @@
|
||||
}
|
||||
|
||||
/* Table editor. */
|
||||
.filter {
|
||||
/* The table editor's WHERE | ORDER BY row (IDEA). */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
height: var(--h-header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.filterField {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.filterField + .filterField {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.filterField > input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
@@ -663,27 +688,38 @@
|
||||
user-select: text;
|
||||
}
|
||||
.sqlPreview :global(.tok-keyword),
|
||||
.sqlPreview :global(.tok-operatorKeyword) {
|
||||
.codeText :global(.tok-keyword),
|
||||
.sqlPreview :global(.tok-operatorKeyword),
|
||||
.codeText :global(.tok-operatorKeyword) {
|
||||
color: var(--syn-keyword);
|
||||
}
|
||||
.sqlPreview :global(.tok-string),
|
||||
.sqlPreview :global(.tok-string2) {
|
||||
.codeText :global(.tok-string),
|
||||
.sqlPreview :global(.tok-string2),
|
||||
.codeText :global(.tok-string2) {
|
||||
color: var(--syn-string);
|
||||
}
|
||||
.sqlPreview :global(.tok-number),
|
||||
.codeText :global(.tok-number),
|
||||
.sqlPreview :global(.tok-bool),
|
||||
.sqlPreview :global(.tok-atom) {
|
||||
.codeText :global(.tok-bool),
|
||||
.sqlPreview :global(.tok-atom),
|
||||
.codeText :global(.tok-atom) {
|
||||
color: var(--syn-number);
|
||||
}
|
||||
.sqlPreview :global(.tok-typeName) {
|
||||
.sqlPreview :global(.tok-typeName),
|
||||
.codeText :global(.tok-typeName) {
|
||||
color: var(--syn-keyword);
|
||||
}
|
||||
.sqlPreview :global(.tok-comment) {
|
||||
.sqlPreview :global(.tok-comment),
|
||||
.codeText :global(.tok-comment) {
|
||||
color: var(--syn-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
.sqlPreview :global(.tok-punctuation),
|
||||
.sqlPreview :global(.tok-operator) {
|
||||
.codeText :global(.tok-punctuation),
|
||||
.sqlPreview :global(.tok-operator),
|
||||
.codeText :global(.tok-operator) {
|
||||
color: var(--syn-punct);
|
||||
}
|
||||
.modifyBody {
|
||||
@@ -776,3 +812,393 @@
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Result views: grid + Record / Value / Aggregates panel. */
|
||||
.dataView {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.dataView > .fill {
|
||||
min-width: 0;
|
||||
}
|
||||
.headerFilter {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.headerFilter:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.headerFilterOn {
|
||||
color: var(--accent);
|
||||
}
|
||||
.filterPill {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-popup);
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
box-shadow: var(--shadow-popup);
|
||||
}
|
||||
.filterPillClear {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filterPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 280px;
|
||||
padding: 8px;
|
||||
}
|
||||
.filterValues {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.filterValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--h-row);
|
||||
}
|
||||
.filterValue > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filterCount {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.filterEmpty {
|
||||
padding: 12px;
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.filterButtons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.sidePanel {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 320px;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.sidePanelHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidePanelBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.recordFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.recordField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.recordLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.valueView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.valueText {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
resize: none;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg-strong);
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.valueText:focus {
|
||||
outline: none;
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
.aggregates {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.aggregates th,
|
||||
.aggregates td {
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
.aggregates th {
|
||||
color: var(--fg-secondary);
|
||||
width: 40%;
|
||||
}
|
||||
.aggregates td {
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Chart. */
|
||||
.chartBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--h-header);
|
||||
padding: 0 8px;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chartHost {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chartSettings {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
}
|
||||
.chartGrid {
|
||||
stroke: var(--border);
|
||||
}
|
||||
.chartAxis {
|
||||
stroke: var(--fg-muted);
|
||||
}
|
||||
.chartTick {
|
||||
fill: var(--fg-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.chartAxisName {
|
||||
fill: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.chartBarMark {
|
||||
fill: var(--chart-series);
|
||||
fill-opacity: 0.9;
|
||||
}
|
||||
.chartBarMark:hover {
|
||||
fill-opacity: 1;
|
||||
}
|
||||
.chartLine {
|
||||
fill: none;
|
||||
stroke: var(--chart-series);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.chartDot {
|
||||
fill: var(--chart-series);
|
||||
}
|
||||
|
||||
/* Query plan. */
|
||||
.planView {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.planTree {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.planHeader,
|
||||
.planRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.planHeader {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.planOperation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.planParams {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.planProperties {
|
||||
flex: none;
|
||||
width: 320px;
|
||||
align-self: flex-start;
|
||||
border-collapse: collapse;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.planProperties th,
|
||||
.planProperties td {
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid var(--border);
|
||||
word-break: break-word;
|
||||
}
|
||||
.planProperties th {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* Export / history / import dialogs. */
|
||||
.exportDialog {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 420px;
|
||||
padding: 14px;
|
||||
}
|
||||
.exportOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
}
|
||||
.exportOptions .formLabel,
|
||||
.exportPreview .formLabel {
|
||||
text-align: left;
|
||||
}
|
||||
.exportPreview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.exportPreviewText,
|
||||
.historyPreview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.codePreview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-editor);
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
font-size: var(--fs-code);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.codeGutter {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
flex: none;
|
||||
min-width: 36px;
|
||||
min-height: 100%;
|
||||
padding: 8px 10px 8px 8px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-editor);
|
||||
color: var(--fg-muted);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
.codeText {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--fg-editor, var(--fg-strong));
|
||||
font: inherit;
|
||||
white-space: pre;
|
||||
user-select: text;
|
||||
}
|
||||
.historyDialog {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 440px;
|
||||
padding: 14px;
|
||||
}
|
||||
.historyList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.historyItem {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.historyText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
}
|
||||
.importPreview {
|
||||
max-height: 200px;
|
||||
margin: 0 14px 14px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
.importPreview table {
|
||||
border-collapse: collapse;
|
||||
font-family: var(--openidea-editor-font, var(--font-mono));
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.importPreview td {
|
||||
padding: 2px 8px;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.importHeader td {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Database tool window (IDEA's layout): data source → database (PostgreSQL) → schema → `tables` /
|
||||
* `views` folders → table → `columns` / `keys` / `foreign keys` / `indexes`,
|
||||
* `views` / `materialized views` / `routines` / `sequences` folders → table → `columns` / `keys` /
|
||||
* `foreign keys` / `indexes` / `triggers`,
|
||||
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
|
||||
* source to open its query console.
|
||||
*/
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { colorVar, databaseKey, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
|
||||
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
|
||||
import { colorVar, databaseKey, passesObjectFilter, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
|
||||
import { nodeTarget, schemaObjectOf, tableFolder, type DbNode as Node, type Folder, type Group } from "./nodes";
|
||||
import {
|
||||
connect,
|
||||
detailsKey,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
loadSources,
|
||||
loadTables,
|
||||
openConsole,
|
||||
openObjectDdl,
|
||||
tablesKey,
|
||||
useDatabase,
|
||||
type Loaded,
|
||||
@@ -46,8 +48,19 @@ const GROUP_LABELS: Record<Group, string> = {
|
||||
keys: "keys",
|
||||
foreignKeys: "foreign keys",
|
||||
indexes: "indexes",
|
||||
triggers: "triggers",
|
||||
};
|
||||
|
||||
const FOLDER_LABELS: Record<Folder, string> = {
|
||||
tables: "tables",
|
||||
views: "views",
|
||||
materializedViews: "materialized views",
|
||||
routines: "routines",
|
||||
sequences: "sequences",
|
||||
};
|
||||
|
||||
const FOLDERS: readonly Folder[] = ["tables", "views", "materializedViews", "routines", "sequences"];
|
||||
|
||||
function status<T>(key: string, l: Loaded<T> | undefined, map: (v: T) => Node[]): Node[] | undefined {
|
||||
if (!l) return undefined;
|
||||
if (l.status === "loading") return undefined;
|
||||
@@ -64,6 +77,7 @@ function groupsOf(d: DbTableDetails): Group[] {
|
||||
if (primaryKey(d).length) g.push("keys");
|
||||
if (d.foreignKeys.length) g.push("foreignKeys");
|
||||
if (d.indexes.length) g.push("indexes");
|
||||
if (d.triggers.length) g.push("triggers");
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -71,6 +85,7 @@ function groupSize(d: DbTableDetails, g: Group): number {
|
||||
if (g === "columns") return d.columns.length;
|
||||
if (g === "keys") return primaryKey(d).length ? 1 : 0;
|
||||
if (g === "foreignKeys") return d.foreignKeys.length;
|
||||
if (g === "triggers") return d.triggers.length;
|
||||
return d.indexes.length;
|
||||
}
|
||||
|
||||
@@ -96,9 +111,24 @@ export function DatabaseView() {
|
||||
[st.sources],
|
||||
);
|
||||
|
||||
/** Schemas tab › Object filter of the data source of target `id`. */
|
||||
const shown = (id: string) => {
|
||||
const filter = sourceOf(id)?.introspection?.objectFilter;
|
||||
return (o: { name: string }) => passesObjectFilter(o.name, filter);
|
||||
};
|
||||
const tablesOf = (id: string, schema: string) => {
|
||||
const t = st.tables[tablesKey(id, schema)];
|
||||
return t?.status === "ok" ? t.value : null;
|
||||
return t?.status === "ok" ? t.value.filter(shown(id)) : null;
|
||||
};
|
||||
const routinesOf = (id: string, schema: string) =>
|
||||
(st.objects[tablesKey(id, schema)]?.routines ?? []).filter(shown(id));
|
||||
const sequencesOf = (id: string, schema: string) =>
|
||||
(st.objects[tablesKey(id, schema)]?.sequences ?? []).filter(shown(id));
|
||||
/** Members of a schema folder: tables / views / materialized views, routines or sequences. */
|
||||
const folderSize = (id: string, schema: string, folder: Folder): number => {
|
||||
if (folder === "routines") return routinesOf(id, schema).length;
|
||||
if (folder === "sequences") return sequencesOf(id, schema).length;
|
||||
return (tablesOf(id, schema) ?? []).filter((t) => tableFolder(t.kind) === folder).length;
|
||||
};
|
||||
const detailsOf = (id: string, schema: string, table: string) => {
|
||||
const d = st.details[detailsKey(id, schema, table)];
|
||||
@@ -140,15 +170,36 @@ export function DatabaseView() {
|
||||
case "database":
|
||||
return schemasOf(n.key, n.id);
|
||||
case "schema":
|
||||
return status(n.key, st.tables[tablesKey(n.id, n.schema)], (v) =>
|
||||
(["tables", "views"] as const)
|
||||
.filter((f) => v.some((t) => (t.kind === "view") === (f === "views")))
|
||||
.map((folder) => ({ kind: "folder", key: `${n.key}/${folder}`, id: n.id, schema: n.schema, folder })),
|
||||
return status(n.key, st.tables[tablesKey(n.id, n.schema)], () =>
|
||||
FOLDERS.filter((f) => folderSize(n.id, n.schema, f) > 0).map((folder): Node => ({
|
||||
kind: "folder",
|
||||
key: `${n.key}/${folder}`,
|
||||
id: n.id,
|
||||
schema: n.schema,
|
||||
folder,
|
||||
})),
|
||||
);
|
||||
case "folder":
|
||||
case "folder": {
|
||||
const at = { id: n.id, schema: n.schema };
|
||||
if (n.folder === "routines")
|
||||
// Overloads share a name: the key tells them apart by their arguments.
|
||||
return routinesOf(n.id, n.schema).map((routine) => ({
|
||||
kind: "routine",
|
||||
key: `${n.key}/${routine.name}(${routine.arguments})`,
|
||||
routine,
|
||||
...at,
|
||||
}));
|
||||
if (n.folder === "sequences")
|
||||
return sequencesOf(n.id, n.schema).map((sequence) => ({
|
||||
kind: "sequence",
|
||||
key: `${n.key}/${sequence.name}`,
|
||||
sequence,
|
||||
...at,
|
||||
}));
|
||||
return (tablesOf(n.id, n.schema) ?? [])
|
||||
.filter((t) => (t.kind === "view") === (n.folder === "views"))
|
||||
.map((table) => ({ kind: "table", key: `${n.key}/${table.name}`, id: n.id, schema: n.schema, table }));
|
||||
.filter((t) => tableFolder(t.kind) === n.folder)
|
||||
.map((table) => ({ kind: "table", key: `${n.key}/${table.name}`, table, ...at }));
|
||||
}
|
||||
case "table": {
|
||||
const d = st.details[detailsKey(n.id, n.schema, n.table.name)];
|
||||
if (!d || d.status === "loading") return undefined;
|
||||
@@ -182,6 +233,8 @@ export function DatabaseView() {
|
||||
return d.foreignKeys.map((fk, i) => ({ kind: "foreignKey", key: `${n.key}/${i}`, fk, ...at }));
|
||||
case "indexes":
|
||||
return d.indexes.map((index) => ({ kind: "index", key: `${n.key}/${index.name}`, index, ...at }));
|
||||
case "triggers":
|
||||
return d.triggers.map((trigger) => ({ kind: "trigger", key: `${n.key}/${trigger.name}`, trigger, ...at }));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -211,7 +264,13 @@ export function DatabaseView() {
|
||||
case "schema":
|
||||
return n.schema;
|
||||
case "folder":
|
||||
return n.folder;
|
||||
return FOLDER_LABELS[n.folder];
|
||||
case "routine":
|
||||
return n.routine.name;
|
||||
case "sequence":
|
||||
return n.sequence.name;
|
||||
case "trigger":
|
||||
return n.trigger.name;
|
||||
case "table":
|
||||
return n.table.name;
|
||||
case "group":
|
||||
@@ -247,9 +306,13 @@ export function DatabaseView() {
|
||||
return s?.status === "ok" ? String(shownSchemas(n.id, s.value).length) : undefined;
|
||||
}
|
||||
case "folder":
|
||||
return String(
|
||||
(tablesOf(n.id, n.schema) ?? []).filter((t) => (t.kind === "view") === (n.folder === "views")).length,
|
||||
);
|
||||
return String(folderSize(n.id, n.schema, n.folder));
|
||||
case "routine":
|
||||
return `(${n.routine.arguments})${n.routine.returns ? `: ${n.routine.returns}` : ""}`;
|
||||
case "sequence":
|
||||
return n.sequence.dataType;
|
||||
case "trigger":
|
||||
return `${n.trigger.timing} ${n.trigger.events}`.trim();
|
||||
case "group": {
|
||||
const d = detailsOf(n.id, n.schema, n.table);
|
||||
return d ? String(groupSize(d, n.group)) : undefined;
|
||||
@@ -279,7 +342,13 @@ export function DatabaseView() {
|
||||
case "group":
|
||||
return "db-folder";
|
||||
case "table":
|
||||
return n.table.kind === "view" ? "db-view" : "db-table";
|
||||
return n.table.kind === "view" ? "db-view" : n.table.kind === "materializedView" ? "db-matview" : "db-table";
|
||||
case "routine":
|
||||
return "db-routine";
|
||||
case "sequence":
|
||||
return "db-sequence";
|
||||
case "trigger":
|
||||
return "db-trigger";
|
||||
case "column":
|
||||
return n.column.primaryKey ? "db-column-key" : n.foreignKey ? "db-column-fk" : "db-column";
|
||||
case "key":
|
||||
@@ -387,6 +456,10 @@ export function DatabaseView() {
|
||||
if (n.kind === "table") openTableData(n.id, n.schema, n.table.name);
|
||||
else if (n.kind === "source") void openConsole(n.source.id ?? "");
|
||||
else if (n.kind === "database") void openConsole(n.id);
|
||||
else {
|
||||
const o = schemaObjectOf(n);
|
||||
if (o) void openObjectDdl(o.id, o.schema, o.kind, o.name);
|
||||
}
|
||||
}}
|
||||
activateExpandable={(n) => n.kind === "table"}
|
||||
onContextMenu={(n, _sel, e) => {
|
||||
|
||||
@@ -8,6 +8,9 @@ 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("");
|
||||
@@ -178,6 +181,13 @@ export function DatabaseDialogs() {
|
||||
/>
|
||||
);
|
||||
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} />
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* IDEA's Export Data dialog: extractor (format) and its options on the left,
|
||||
* a live preview on the right; copies to the clipboard or writes a file.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, IconButton, Input, Select, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import {
|
||||
DEFAULT_EXPORT_OPTIONS,
|
||||
EXPORT_FORMATS,
|
||||
exportData,
|
||||
type ExportData,
|
||||
type ExportFormat,
|
||||
type ExportOptions,
|
||||
} from "./export";
|
||||
import { CodePreview } from "./CodePreview";
|
||||
import { closeDialog } from "./store";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
const PREVIEW_ROWS = 100;
|
||||
/** Formats with a tabular layout (the options apply). */
|
||||
const TABULAR: ReadonlySet<ExportFormat> = new Set(["csv", "tsv", "markdown"]);
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
return `${dir.replace(/[\\/]+$/, "")}/${name}`;
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
data,
|
||||
source,
|
||||
initialFormat = "csv",
|
||||
}: {
|
||||
data: ExportData;
|
||||
source: string;
|
||||
initialFormat?: ExportFormat;
|
||||
}) {
|
||||
const [format, setFormat] = useState<ExportFormat>(initialFormat);
|
||||
const [options, setOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
|
||||
const ext = EXPORT_FORMATS.find((f) => f.id === format)!.extension;
|
||||
const [file, setFile] = useState(`${data.table}.${ext}`);
|
||||
const [dir, setDir] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void import("@tauri-apps/api/path")
|
||||
.then((p) => p.homeDir())
|
||||
.then((home) => {
|
||||
setDir(home);
|
||||
setFile((f) => (f.includes("/") || f.includes("\\") ? f : joinPath(home, f)));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const changeFormat = (f: ExportFormat) => {
|
||||
setFormat(f);
|
||||
// The file follows the format's extension.
|
||||
const next = EXPORT_FORMATS.find((x) => x.id === f)!.extension;
|
||||
setFile((path) => path.replace(/\.[^./\\]*$/, `.${next}`));
|
||||
};
|
||||
|
||||
const tabular = TABULAR.has(format);
|
||||
const effective = tabular ? options : DEFAULT_EXPORT_OPTIONS;
|
||||
const preview = useMemo(
|
||||
() => exportData(format, { ...data, rows: data.rows.slice(0, PREVIEW_ROWS) }, effective),
|
||||
[format, data, effective],
|
||||
);
|
||||
const text = () => exportData(format, data, effective);
|
||||
const set = (k: keyof ExportOptions) => (v: boolean) => setOptions((o) => ({ ...o, [k]: v }));
|
||||
|
||||
const browse = async () => {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
|
||||
const path = await save({ defaultPath: file, filters: [{ name: f.label, extensions: [f.extension] }] });
|
||||
if (path) setFile(path);
|
||||
} catch {
|
||||
// No dialog (tests): keep typing the path.
|
||||
}
|
||||
};
|
||||
|
||||
const toFile = async () => {
|
||||
const path = file.trim();
|
||||
if (!path) return;
|
||||
const target = /^([a-zA-Z]:)?[\\/]/.test(path) || !dir ? path : joinPath(dir, path);
|
||||
try {
|
||||
await call(commands.dbExportFile(target, text()));
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: `${data.rows.length} rows exported`,
|
||||
body: target,
|
||||
key: "db-export",
|
||||
});
|
||||
closeDialog();
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot export data",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={closeDialog}
|
||||
title="Export Data"
|
||||
width={860}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{
|
||||
label: "Copy to Clipboard",
|
||||
onClick: () => {
|
||||
void navigator.clipboard?.writeText(text());
|
||||
closeDialog();
|
||||
},
|
||||
},
|
||||
{ label: "Export to File", role: "default", disabled: !file.trim(), onClick: () => void toFile() },
|
||||
]}
|
||||
>
|
||||
<div className={styles.exportDialog}>
|
||||
<div className={styles.exportOptions}>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Source:</T>
|
||||
</span>
|
||||
<Input readOnly value={source} aria-label="Source" />
|
||||
<span className={styles.formLabel}>
|
||||
<T>Extractor:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="Extractor"
|
||||
value={format}
|
||||
options={EXPORT_FORMATS.map((f) => ({ value: f.id, label: f.label }))}
|
||||
onChange={(v) => changeFormat(v as ExportFormat)}
|
||||
/>
|
||||
<Checkbox checked={effective.transpose} disabled={!tabular} onChange={set("transpose")} label="Transpose" />
|
||||
<Checkbox
|
||||
checked={effective.columnHeader}
|
||||
disabled={!tabular}
|
||||
onChange={set("columnHeader")}
|
||||
label="Add column header"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={effective.rowHeader}
|
||||
disabled={!tabular}
|
||||
onChange={set("rowHeader")}
|
||||
label="Add row header"
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Output file:</T>
|
||||
</span>
|
||||
<div className={styles.formRow}>
|
||||
<Input mono aria-label="Output file" value={file} onChange={(e) => setFile(e.target.value)} />
|
||||
<IconButton icon="folder" title="Browse…" onClick={() => void browse()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.exportPreview}>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Export preview:</T>
|
||||
</span>
|
||||
<CodePreview
|
||||
className={styles.exportPreviewText}
|
||||
aria-label="Export preview"
|
||||
sql={format === "sql"}
|
||||
text={preview + (data.rows.length > PREVIEW_ROWS ? `\n… ${data.rows.length - PREVIEW_ROWS} more rows` : "")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -42,9 +42,25 @@ export interface GridProps {
|
||||
rowMenu?: (rows: number[]) => MenuItem[];
|
||||
/** Row number shown for row 0 (paging). */
|
||||
firstRowNumber?: number;
|
||||
/** Row number shown for a row (local sort / filter reorder them); `firstRowNumber + row` otherwise. */
|
||||
rowNumber?: (row: number) => number;
|
||||
/** The selected range changed (the Record / Value / Aggregates panel follows it). */
|
||||
onSelectionChange?: (range: GridRange | null) => void;
|
||||
/** The header's filter button (IDEA's local filter); `filtered` marks the columns with one. */
|
||||
onFilter?: (col: number, anchor: HTMLElement) => void;
|
||||
filtered?: (col: number) => boolean;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/** A selected block of cells, inclusive; `lead` is the cell last moved to. */
|
||||
export interface GridRange {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
lead: { row: number; col: number };
|
||||
}
|
||||
|
||||
interface Sel {
|
||||
r0: number;
|
||||
c0: number;
|
||||
@@ -55,11 +71,16 @@ interface Sel {
|
||||
const ROW_H = 24;
|
||||
const NUM_W = 48;
|
||||
|
||||
function initialWidth(c: GridColumn, sample: Cell[]): number {
|
||||
const longest = Math.max(c.name.length + 2, ...sample.map((v) => Math.min((v ?? "<null>").length, 60)));
|
||||
return Math.max(64, Math.min(320, longest * 7.5 + 20));
|
||||
/** Wide enough for the whole header (icon, name, filter, sort) and the longest sampled value. */
|
||||
function initialWidth(c: GridColumn, sample: Cell[], decorations: number): number {
|
||||
const header = c.name.length * 8 + 32 + decorations;
|
||||
const longest = Math.max(...sample.map((v) => Math.min((v ?? "<null>").length, 60)), 0) * 7.5 + 20;
|
||||
return Math.max(64, Math.min(360, Math.max(header, longest)));
|
||||
}
|
||||
|
||||
/** SQLite renders whole REALs as `3108.0`; IDEA shows `3108`. */
|
||||
const displayed = (v: string, numeric: boolean) => (numeric && /^-?\d+\.0$/.test(v) ? v.slice(0, -2) : v);
|
||||
|
||||
function norm(s: Sel) {
|
||||
return {
|
||||
top: Math.min(s.r0, s.r1),
|
||||
@@ -80,7 +101,8 @@ export function Grid(props: GridProps) {
|
||||
useEffect(() => {
|
||||
const sampleRows = Math.min(rowCount, 50);
|
||||
const samples = columns.map((_, ci) => Array.from({ length: sampleRows }, (_, r) => value(r, ci)));
|
||||
setWidths(columns.map((c, ci) => initialWidth(c, samples[ci]!)));
|
||||
const decorations = (props.onFilter ? 20 : 0) + (onSort ? 20 : 0);
|
||||
setWidths(columns.map((c, ci) => initialWidth(c, samples[ci]!, decorations)));
|
||||
setNumeric(columns.map((c, ci) => isNumericColumn(c.type, samples[ci]!)));
|
||||
setSel(null);
|
||||
setEditing(null);
|
||||
@@ -88,6 +110,13 @@ export function Grid(props: GridProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnKey]);
|
||||
|
||||
const { onSelectionChange } = props;
|
||||
useEffect(() => {
|
||||
onSelectionChange?.(
|
||||
sel && rowCount ? { ...norm(sel), lead: { row: Math.min(sel.r1, rowCount - 1), col: sel.c1 } } : null,
|
||||
);
|
||||
}, [sel, rowCount, onSelectionChange]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -112,7 +141,9 @@ export function Grid(props: GridProps) {
|
||||
const copy = (format: "tsv" | "csv", header: boolean, wholeRows = false) => {
|
||||
if (!sel) return;
|
||||
const n = norm(sel);
|
||||
const cols = wholeRows ? columns.map((_, i) => i) : Array.from({ length: n.right - n.left + 1 }, (_, i) => n.left + i);
|
||||
const cols = wholeRows
|
||||
? columns.map((_, i) => i)
|
||||
: Array.from({ length: n.right - n.left + 1 }, (_, i) => n.left + i);
|
||||
const rows = selectedRows().map((r) => cols.map((c) => value(r, c)));
|
||||
const head = header ? cols.map((c) => columns[c]!.name) : null;
|
||||
const text = format === "tsv" ? toTsv(head, rows) : toCsv(head, rows);
|
||||
@@ -153,7 +184,12 @@ export function Grid(props: GridProps) {
|
||||
startEdit(sel.r1, sel.c1);
|
||||
return;
|
||||
}
|
||||
const moves: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
|
||||
const moves: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
const m = moves[e.key];
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
@@ -250,8 +286,31 @@ export function Grid(props: GridProps) {
|
||||
className={styles.headerIcon}
|
||||
/>
|
||||
<span className={styles.headerName}>{c.name}</span>
|
||||
{s && <Icon name={s.descending ? "arrow-down" : "arrow-up"} size={12} className={styles.headerSort} />}
|
||||
<span className={styles.resizer} onMouseDown={(e) => startResize(e, ci)} onClick={(e) => e.stopPropagation()} />
|
||||
{props.onFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
|
||||
aria-label={`Filter ${c.name}`}
|
||||
title="Local filter"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onFilter?.(ci, e.currentTarget);
|
||||
}}
|
||||
>
|
||||
<Icon name="filter" size={12} />
|
||||
</button>
|
||||
)}
|
||||
{s ? (
|
||||
<Icon name={s.descending ? "arrow-down" : "arrow-up"} size={12} className={styles.headerSort} />
|
||||
) : (
|
||||
onSort && <Icon name="db-sort" size={16} className={styles.headerSort} />
|
||||
)}
|
||||
<span
|
||||
className={styles.resizer}
|
||||
onMouseDown={(e) => startResize(e, ci)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -263,11 +322,15 @@ export function Grid(props: GridProps) {
|
||||
<div
|
||||
key={v.key}
|
||||
role="row"
|
||||
className={clsx(styles.gridRow, state === "deleted" && styles.rowDeleted, state === "inserted" && styles.rowInserted)}
|
||||
className={clsx(
|
||||
styles.gridRow,
|
||||
state === "deleted" && styles.rowDeleted,
|
||||
state === "inserted" && styles.rowInserted,
|
||||
)}
|
||||
style={{ gridTemplateColumns: template, transform: `translateY(${v.start + ROW_H}px)` }}
|
||||
>
|
||||
<div className={styles.rowNumber} onMouseDown={(e) => onRowNumberMouseDown(e, r)}>
|
||||
{state === "inserted" ? "+" : firstRowNumber + r}
|
||||
{state === "inserted" ? "+" : (props.rowNumber?.(r) ?? firstRowNumber + r)}
|
||||
</div>
|
||||
{columns.map((_, c) => {
|
||||
if (editing && editing.row === r && editing.col === c) {
|
||||
@@ -306,7 +369,7 @@ export function Grid(props: GridProps) {
|
||||
onDoubleClick={() => startEdit(r, c)}
|
||||
title={val != null && val.length > 40 ? val.slice(0, 2000) : undefined}
|
||||
>
|
||||
{val === null ? <span className={styles.null}><null></span> : val}
|
||||
{val === null ? <span className={styles.null}><null></span> : displayed(val, !!numeric[c])}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { call, commands, type DbColumnInfo, type DbDriver } from "@/platform/ipc
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
import { Button, Checkbox, Dialog, IconButton, Input, Popup, Icon, T } from "@/ui";
|
||||
import { parseSourceUrl, sourceLabel, splitTarget } from "./model";
|
||||
import { nodeTarget, tableOf, type DbNode } from "./nodes";
|
||||
import { nodeTarget, schemaObjectOf, tableOf, type DbNode } from "./nodes";
|
||||
import {
|
||||
alterTableSql,
|
||||
canAlterColumn,
|
||||
@@ -61,9 +61,14 @@ const driverOf = (id: string): DbDriver => sourceById(id)?.driver ?? "sqlite";
|
||||
const KIND_LABEL: Record<DbObjectRef["kind"], string> = {
|
||||
table: "table",
|
||||
view: "view",
|
||||
materializedView: "materialized view",
|
||||
column: "column",
|
||||
index: "index",
|
||||
schema: "schema",
|
||||
function: "function",
|
||||
procedure: "procedure",
|
||||
trigger: "trigger",
|
||||
sequence: "sequence",
|
||||
};
|
||||
|
||||
function objectName(o: DbObjectRef): string {
|
||||
@@ -530,12 +535,18 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
|
||||
const source = sourceById(id);
|
||||
const t = tableOf(node);
|
||||
const [ddl, setDdl] = useState<string | null>(null);
|
||||
const showDdl = node.kind === "table";
|
||||
const object = schemaObjectOf(node);
|
||||
const showDdl = node.kind === "table" || !!object;
|
||||
useEffect(() => {
|
||||
if (!showDdl || !t) return;
|
||||
if (!showDdl || (!t && !object)) return;
|
||||
let live = true;
|
||||
withConnection(t.id, () => call(commands.dbDdl(t.id, t.schema, t.table)))
|
||||
.then((d) => live && setDdl(readableDdl(d, driverOf(t.id))))
|
||||
const read = object
|
||||
? withConnection(object.id, () => call(commands.dbObjectDdl(object.id, object.schema, object.kind, object.name)))
|
||||
: withConnection(t!.id, () => call(commands.dbDdl(t!.id, t!.schema, t!.table))).then((d) =>
|
||||
readableDdl(d, driverOf(t!.id)),
|
||||
);
|
||||
read
|
||||
.then((d) => live && setDdl(d))
|
||||
.catch((e: unknown) => live && setDdl(`-- ${e instanceof Error ? e.message : String(e)}`));
|
||||
return () => {
|
||||
live = false;
|
||||
@@ -568,6 +579,24 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
|
||||
case "folder":
|
||||
rows.push(["Schema", node.schema]);
|
||||
break;
|
||||
case "routine":
|
||||
rows.push(
|
||||
["Schema", node.schema],
|
||||
[node.routine.kind === "procedure" ? "Procedure" : "Function", node.routine.name],
|
||||
);
|
||||
if (node.routine.arguments) rows.push(["Arguments", node.routine.arguments]);
|
||||
if (node.routine.returns) rows.push(["Returns", node.routine.returns]);
|
||||
break;
|
||||
case "sequence":
|
||||
rows.push(["Schema", node.schema], ["Sequence", node.sequence.name], ["Type", node.sequence.dataType]);
|
||||
break;
|
||||
case "trigger":
|
||||
rows.push(
|
||||
["Table", node.table],
|
||||
["Trigger", node.trigger.name],
|
||||
["Fires", `${node.trigger.timing} ${node.trigger.events}`],
|
||||
);
|
||||
break;
|
||||
default:
|
||||
if (t) rows.push(["Schema", t.schema], [t.view ? "View" : "Table", t.table]);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
* executed statement with its outcome, and a `Result N` tab per result set —
|
||||
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { EmptyState, IconButton, Spinner, Tabs, showContextMenu, type MenuItem, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
|
||||
import { Grid } from "./Grid";
|
||||
import type { DbPlanNode } from "@/platform/ipc";
|
||||
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { ChartView } from "./ChartView";
|
||||
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import { analyze } from "./sql/analyze";
|
||||
import {
|
||||
OUTPUT_TAB,
|
||||
@@ -63,52 +63,17 @@ function queryTable(text: string, data: Pick<ExportData, "driver">): string {
|
||||
return a.refs.find((r) => r.depth === 0 && r.kind === "table")?.name ?? "MY_TABLE";
|
||||
}
|
||||
|
||||
async function saveExport(format: ExportFormat, data: ExportData) {
|
||||
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
|
||||
let path: string | null;
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
path = await save({
|
||||
defaultPath: `${data.table}.${f.extension}`,
|
||||
filters: [{ name: f.label, extensions: [f.extension] }],
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!path) return;
|
||||
try {
|
||||
await call(commands.dbExportFile(path, exportData(format, data)));
|
||||
notifications().notify({ level: "info", title: `${data.rows.length} rows exported`, body: path, key: "db-export" });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot export data",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function exportMenu(data: ExportData): MenuItem[] {
|
||||
return [
|
||||
...EXPORT_FORMATS.map((f): MenuItem => ({
|
||||
label: `Copy as ${f.label}`,
|
||||
icon: "copy",
|
||||
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
|
||||
})),
|
||||
{ kind: "separator" },
|
||||
...EXPORT_FORMATS.map((f): MenuItem => ({
|
||||
label: `Export to ${f.label} File…`,
|
||||
run: () => void saveExport(f.id, data),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function Result({ tab }: { tab: ResultTab }) {
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
const [panel, setPanel] = useState<DataPanel | null>(null);
|
||||
const [extractor, setExtractor] = useState<ExportFormat>("csv");
|
||||
const r = tab.outcome.result;
|
||||
const columns = useMemo(() => (r ? r.columns.map((c) => ({ name: c.name, type: c.typeName })) : []), [r]);
|
||||
const value = useCallback((row: number, col: number) => r?.rows[row]?.[col] ?? null, [r]);
|
||||
if (!r) return null;
|
||||
const driver = sourceById(tab.sourceId)?.driver ?? "sqlite";
|
||||
const data = (): ExportData => ({
|
||||
columns: r.columns.map((c) => ({ name: c.name, type: c.typeName })),
|
||||
columns,
|
||||
rows: r.rows,
|
||||
driver,
|
||||
table: queryTable(tab.outcome.statement.text, { driver }),
|
||||
@@ -116,25 +81,153 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
|
||||
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<IconButton icon="export" title="Export Data" onClick={(e) => showContextMenu(e, exportMenu(data()))} />
|
||||
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||
{tab.busy && <Spinner size={12} />}
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||
{targetLabel(tab.sourceId)}
|
||||
</span>
|
||||
<ExtractorSelect value={extractor} onChange={setExtractor} />
|
||||
<IconButton
|
||||
icon="export"
|
||||
title="Export Data"
|
||||
onClick={(e) =>
|
||||
showContextMenu(e, exportMenu(data(), queryTable(tab.outcome.statement.text, { driver }), extractor))
|
||||
}
|
||||
/>
|
||||
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
|
||||
</div>
|
||||
{view === "chart" ? (
|
||||
<div className={styles.fill}>
|
||||
<ChartView columns={columns} rows={r.rows} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<DataView
|
||||
columns={columns}
|
||||
rowCount={r.rows.length}
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
aria-label={tab.title}
|
||||
/>
|
||||
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
|
||||
{r.rows.length} row{r.rows.length === 1 ? "" : "s"}
|
||||
{r.hasMore ? " (limit reached)" : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlanRow {
|
||||
node: DbPlanNode;
|
||||
index: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** IDEA's operation icons: a table for scans of one, a key for index lookups, an arrow for the statement. */
|
||||
function planIcon(r: PlanRow): string | null {
|
||||
if (r.index < 0) return "arrow-left";
|
||||
if (/index/i.test(r.node.operation)) return "db-key";
|
||||
if (/scan/i.test(r.node.operation)) return "db-table";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** IDEA's Query Plan tab: the operation tree with its parameters, the selected operation's properties beside it. */
|
||||
function PlanResult({ tab }: { tab: ResultTab }) {
|
||||
const plan = useMemo(() => tab.plan ?? [], [tab.plan]);
|
||||
const rows = useMemo(() => plan.map((node, index): PlanRow => ({ node, index, key: String(index) })), [plan]);
|
||||
// IDEA roots the operations in the statement itself ("Select").
|
||||
const statement = useMemo((): PlanRow => {
|
||||
const kind = /^\s*(\w+)/.exec(tab.outcome.statement.text)?.[1]?.toLowerCase() ?? "statement";
|
||||
const operation = kind[0]!.toUpperCase() + kind.slice(1);
|
||||
return {
|
||||
node: { parent: null, operation, params: "", properties: [{ name: "Operation", value: kind }] },
|
||||
index: -1,
|
||||
key: "statement",
|
||||
};
|
||||
}, [tab.outcome.statement.text]);
|
||||
const [selected, setSelected] = useState(-1);
|
||||
// Everything starts expanded, a re-explained plan too.
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
||||
const keys = useMemo(() => [statement.key, ...rows.map((r) => r.key)], [statement, rows]);
|
||||
const expanded = useMemo(() => new Set(keys.filter((k) => !collapsed.has(k))), [keys, collapsed]);
|
||||
const setExpanded = (next: ReadonlySet<string>) => setCollapsed(new Set(keys.filter((k) => !next.has(k))));
|
||||
const children = (r: PlanRow) =>
|
||||
r === statement ? rows.filter((c) => c.node.parent === null) : rows.filter((c) => c.node.parent === r.index);
|
||||
const props = (selected < 0 ? statement : rows[selected])?.node.properties ?? [];
|
||||
const selectedKey = selected < 0 ? statement.key : String(selected);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||
{tab.busy && <Spinner size={12} />}
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||
{targetLabel(tab.sourceId)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<Grid
|
||||
columns={r.columns.map((c) => ({ name: c.name, type: c.typeName }))}
|
||||
rowCount={r.rows.length}
|
||||
value={(row, col) => r.rows[row]?.[col] ?? null}
|
||||
/>
|
||||
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
|
||||
{r.rows.length} row{r.rows.length === 1 ? "" : "s"}
|
||||
{r.hasMore ? " (limit reached)" : ""}
|
||||
<div className={clsx(styles.fill, styles.planView)}>
|
||||
<div className={styles.planTree}>
|
||||
<div className={styles.planHeader}>
|
||||
<span>
|
||||
<T>Operation</T>
|
||||
</span>
|
||||
<span>
|
||||
<T>Params</T>
|
||||
</span>
|
||||
</div>
|
||||
{plan.length ? (
|
||||
<Tree<PlanRow>
|
||||
aria-label="Query plan"
|
||||
roots={[statement]}
|
||||
selected={new Set([selectedKey])}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
getKey={(r) => r.key}
|
||||
getLabel={(r) => r.node.operation}
|
||||
getChildren={(r) => children(r)}
|
||||
isExpandable={(r) => children(r).length > 0}
|
||||
onSelectionChange={(_, lead) => lead && setSelected(lead.index)}
|
||||
renderNode={(r) => (
|
||||
<span className={styles.planRow}>
|
||||
<span className={styles.planOperation}>
|
||||
{planIcon(r) && <Icon name={planIcon(r)!} size={16} />}
|
||||
{r.node.operation}
|
||||
</span>
|
||||
<span className={styles.planParams}>{r.node.params}</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="Empty plan" />
|
||||
)}
|
||||
</div>
|
||||
<table className={styles.planProperties} aria-label="Plan node properties">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<T>Property</T>
|
||||
</th>
|
||||
<th>
|
||||
<T>Value</T>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.map((p) => (
|
||||
<tr key={p.name}>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -164,13 +257,15 @@ export function ResultsView() {
|
||||
...results.map((r) => ({
|
||||
id: String(r.id),
|
||||
label: r.title,
|
||||
icon: "db-table",
|
||||
icon: r.plan ? "db-plan" : "db-table",
|
||||
closable: true,
|
||||
tooltip: r.outcome.statement.text,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className={styles.fill}>{tab ? <Result key={tab.id} tab={tab} /> : <Output />}</div>
|
||||
<div className={styles.fill}>
|
||||
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,15 @@ const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
|
||||
/** Driver property names the Advanced tab suggests, and what the backend does with them. */
|
||||
const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
|
||||
postgres: {
|
||||
names: ["statement_timeout", "lock_timeout", "idle_in_transaction_session_timeout", "search_path", "work_mem", "application_name", "connect_timeout"],
|
||||
names: [
|
||||
"statement_timeout",
|
||||
"lock_timeout",
|
||||
"idle_in_transaction_session_timeout",
|
||||
"search_path",
|
||||
"work_mem",
|
||||
"application_name",
|
||||
"connect_timeout",
|
||||
],
|
||||
hint: "Sent as run-time parameters when connecting (any server setting, e.g. statement_timeout). application_name names the session; connect_timeout is in seconds.",
|
||||
},
|
||||
mysql: {
|
||||
@@ -90,6 +98,7 @@ const blankOptions = (): DbOptions => ({
|
||||
startupScript: "",
|
||||
keepAliveSecs: null,
|
||||
autoDisconnectSecs: null,
|
||||
singleDatabase: false,
|
||||
});
|
||||
|
||||
const blankIntrospection = (): DbIntrospection => ({
|
||||
@@ -98,6 +107,8 @@ const blankIntrospection = (): DbIntrospection => ({
|
||||
allSchemas: true,
|
||||
schemas: [],
|
||||
showSystem: false,
|
||||
showTemplates: false,
|
||||
objectFilter: "",
|
||||
});
|
||||
|
||||
const parsePort = (v: string) => {
|
||||
@@ -326,7 +337,8 @@ export function SourceDialog({
|
||||
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
|
||||
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
|
||||
const options: DbOptions = { ...blankOptions(), ...s.options };
|
||||
const setOptions = (patch: Partial<DbOptions>) => setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
|
||||
const setOptions = (patch: Partial<DbOptions>) =>
|
||||
setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
|
||||
const intro: DbIntrospection = { ...blankIntrospection(), ...s.introspection };
|
||||
const setIntro = (patch: Partial<DbIntrospection>) =>
|
||||
setS((x) => ({ ...x, introspection: { ...blankIntrospection(), ...x.introspection, ...patch } }));
|
||||
@@ -342,7 +354,8 @@ export function SourceDialog({
|
||||
const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver;
|
||||
const host = urlOnly ? (parsedUrl?.source.host ?? "") : (s.host ?? "");
|
||||
const database = urlOnly ? (parsedUrl?.source.database ?? "") : (s.database ?? "");
|
||||
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
|
||||
const name =
|
||||
s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
|
||||
const sshOn = !sqlite && !!ssh.enabled;
|
||||
const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim());
|
||||
const sslOn = (s.ssl ?? "prefer") !== "disable";
|
||||
@@ -472,7 +485,12 @@ export function SourceDialog({
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
|
||||
<Input
|
||||
id="db-host"
|
||||
className={styles.fill}
|
||||
value={s.host ?? ""}
|
||||
onChange={(e) => set({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-port">
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
@@ -514,7 +532,11 @@ export function SourceDialog({
|
||||
placeholder={initial && password === undefined ? "<unchanged>" : ""}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Checkbox checked={!!s.savePassword} onChange={(savePassword) => set({ savePassword })} label="Save password" />
|
||||
<Checkbox
|
||||
checked={!!s.savePassword}
|
||||
onChange={(savePassword) => set({ savePassword })}
|
||||
label="Save password"
|
||||
/>
|
||||
</div>
|
||||
{!urlOnly && (
|
||||
<>
|
||||
@@ -587,7 +609,9 @@ export function SourceDialog({
|
||||
<Input
|
||||
id="db-time-zone"
|
||||
value={options.timeZone ?? ""}
|
||||
placeholder={postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"}
|
||||
placeholder={
|
||||
postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"
|
||||
}
|
||||
onChange={(e) => setOptions({ timeZone: e.target.value })}
|
||||
/>
|
||||
<SecondsOption
|
||||
@@ -604,6 +628,16 @@ export function SourceDialog({
|
||||
fallback={300}
|
||||
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
|
||||
/>
|
||||
{postgres && (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={!!options.singleDatabase}
|
||||
onChange={(singleDatabase) => setOptions({ singleDatabase })}
|
||||
label="Single database mode (show only the configured database)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-startup">
|
||||
<T>Startup script:</T>
|
||||
</label>
|
||||
@@ -612,7 +646,13 @@ export function SourceDialog({
|
||||
className={styles.textArea}
|
||||
spellCheck={false}
|
||||
value={options.startupScript ?? ""}
|
||||
placeholder={postgres ? "SET statement_timeout = '30s';" : sqlite ? "PRAGMA foreign_keys = ON;" : "SET SESSION sql_mode = 'ANSI';"}
|
||||
placeholder={
|
||||
postgres
|
||||
? "SET statement_timeout = '30s';"
|
||||
: sqlite
|
||||
? "PRAGMA foreign_keys = ON;"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
}
|
||||
onChange={(e) => setOptions({ startupScript: e.target.value })}
|
||||
/>
|
||||
<span />
|
||||
@@ -686,7 +726,9 @@ export function SourceDialog({
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
<T>{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}</T>
|
||||
<T>
|
||||
{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}
|
||||
</T>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -775,11 +817,30 @@ export function SourceDialog({
|
||||
onAll={(allSchemas) => setIntro({ allSchemas })}
|
||||
onChosen={(schemas) => setIntro({ schemas })}
|
||||
/>
|
||||
<div className={styles.formRow}>
|
||||
<label className={styles.formLabel} htmlFor="db-object-filter">
|
||||
<T>Object filter:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-object-filter"
|
||||
mono
|
||||
value={intro.objectFilter ?? ""}
|
||||
placeholder="e.g. order*, customers (tables, views, routines, sequences)"
|
||||
onChange={(e) => setIntro({ objectFilter: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={!!intro.showSystem}
|
||||
onChange={(showSystem) => setIntro({ showSystem })}
|
||||
label={postgres ? "Show system schemas and template databases" : "Show system schemas"}
|
||||
label="Show internal system schemas"
|
||||
/>
|
||||
{postgres && (
|
||||
<Checkbox
|
||||
checked={!!intro.showTemplates}
|
||||
onChange={(showTemplates) => setIntro({ showTemplates })}
|
||||
label="Show template databases"
|
||||
/>
|
||||
)}
|
||||
<div className={styles.formRow}>
|
||||
<Button disabled={!initial?.id || busy} onClick={() => void loadNames()}>
|
||||
<T>Load Names from Server</T>
|
||||
@@ -815,7 +876,12 @@ export function SourceDialog({
|
||||
placeholder="<user defined>"
|
||||
onChange={(e) => setProperty(i, { name: e.target.value })}
|
||||
/>
|
||||
<Input aria-label="Property value" value={p.value ?? ""} placeholder="<value>" onChange={(e) => setProperty(i, { value: e.target.value })} />
|
||||
<Input
|
||||
aria-label="Property value"
|
||||
value={p.value ?? ""}
|
||||
placeholder="<value>"
|
||||
onChange={(e) => setProperty(i, { value: e.target.value })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="small"
|
||||
@@ -871,7 +937,13 @@ export function SourceDialog({
|
||||
<T>Name:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input id="db-name" className={styles.fill} value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
|
||||
<Input
|
||||
id="db-name"
|
||||
className={styles.fill}
|
||||
value={s.name ?? ""}
|
||||
placeholder={name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Color:</T>
|
||||
</span>
|
||||
@@ -883,7 +955,9 @@ export function SourceDialog({
|
||||
onChange={(c) => set({ color: c === "none" ? null : c })}
|
||||
renderValue={(o) => (
|
||||
<span className={styles.colorValue}>
|
||||
{o && o.value !== "none" && <span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />}
|
||||
{o && o.value !== "none" && (
|
||||
<span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />
|
||||
)}
|
||||
<T>{o?.label ?? "No color"}</T>
|
||||
</span>
|
||||
)}
|
||||
@@ -932,4 +1006,3 @@ export function SourceDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* a WHERE filter, and pending cell edits / added / deleted rows submitted
|
||||
* together in one transaction. Tables without a primary key are read-only.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
@@ -13,11 +13,20 @@ import {
|
||||
type DbTablePage,
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { Button, EmptyState, IconButton, Input, Select, Spinner, T } from "@/ui";
|
||||
import { Button, EmptyState, Icon, IconButton, Input, Select, Spinner, T, showContextMenu } from "@/ui";
|
||||
import { notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import { Grid } from "./Grid";
|
||||
import { buildRowEdits, emptyEdits, hasEdits, type Cell, type PendingEdits } from "./model";
|
||||
import { sourceById, withConnection } from "./store";
|
||||
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportFormat } from "./export";
|
||||
import {
|
||||
buildRowEdits,
|
||||
emptyEdits,
|
||||
formatOrderBy,
|
||||
hasEdits,
|
||||
parseOrderBy,
|
||||
type Cell,
|
||||
type PendingEdits,
|
||||
} from "./model";
|
||||
import { openDialog, sourceById, withConnection } from "./store";
|
||||
import type { TableRef } from "./tableInput";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
@@ -34,6 +43,10 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const [edits, setEdits] = useState<PendingEdits>(emptyEdits);
|
||||
const [panel, setPanel] = useState<DataPanel | null>(null);
|
||||
const [extractor, setExtractor] = useState<ExportFormat>("csv");
|
||||
// The ORDER BY field while it's being edited; it shows the sort otherwise.
|
||||
const [orderDraft, setOrderDraft] = useState<string | null>(null);
|
||||
// The page for the current parameters (`key`); stale while loading.
|
||||
const [loadedPage, setLoadedPage] = useState<{ key: string; page: DbTablePage | null; error: string | null } | null>(
|
||||
null,
|
||||
@@ -77,17 +90,34 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
const editable = pk.length > 0 && !sourceById(ref.id)?.readOnly;
|
||||
const loaded = page?.result.rows.length ?? 0;
|
||||
const rowCount = loaded + edits.inserts.length;
|
||||
const gridColumns = useMemo(
|
||||
() =>
|
||||
columns.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.typeName,
|
||||
primaryKey: pk.includes(c.name),
|
||||
foreignKey: fkColumns.has(c.name),
|
||||
})),
|
||||
// `pk` / `fkColumns` derive from `page`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[page],
|
||||
);
|
||||
|
||||
const value = (row: number, col: number): Cell => {
|
||||
const name = names[col]!;
|
||||
if (row >= loaded) {
|
||||
const ins = edits.inserts[row - loaded];
|
||||
return ins?.has(name) ? ins.get(name)! : null;
|
||||
}
|
||||
const upd = edits.updates.get(row);
|
||||
if (upd?.has(name)) return upd.get(name)!;
|
||||
return page?.result.rows[row]?.[col] ?? null;
|
||||
};
|
||||
const value = useCallback(
|
||||
(row: number, col: number): Cell => {
|
||||
const name = names[col]!;
|
||||
if (row >= loaded) {
|
||||
const ins = edits.inserts[row - loaded];
|
||||
return ins?.has(name) ? ins.get(name)! : null;
|
||||
}
|
||||
const upd = edits.updates.get(row);
|
||||
if (upd?.has(name)) return upd.get(name)!;
|
||||
return page?.result.rows[row]?.[col] ?? null;
|
||||
},
|
||||
// `names` / `loaded` derive from `page`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[page, edits],
|
||||
);
|
||||
|
||||
const onEdit = (row: number, col: number, v: Cell) => {
|
||||
const name = names[col]!;
|
||||
@@ -144,6 +174,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
});
|
||||
|
||||
const dirty = hasEdits(edits);
|
||||
const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
@@ -175,20 +206,6 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.filter}
|
||||
mono
|
||||
aria-label="WHERE filter"
|
||||
placeholder="WHERE (e.g. id > 10)"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setOffset(0);
|
||||
setFilter(filterText.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{editable && (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -205,6 +222,77 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
</>
|
||||
)}
|
||||
{loading && <Spinner size={12} />}
|
||||
<span className={styles.spacer} />
|
||||
<ExtractorSelect value={extractor} onChange={setExtractor} />
|
||||
<IconButton
|
||||
icon="export"
|
||||
title="Export Data"
|
||||
disabled={!page}
|
||||
onClick={(e) =>
|
||||
page &&
|
||||
showContextMenu(
|
||||
e,
|
||||
exportMenu(
|
||||
{
|
||||
columns: gridColumns,
|
||||
rows: page.result.rows,
|
||||
table: ref.table,
|
||||
driver: sourceById(ref.id)?.driver ?? "sqlite",
|
||||
},
|
||||
`${ref.schema}.${ref.table}`,
|
||||
extractor,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
icon="import"
|
||||
title="Import Data from File…"
|
||||
disabled={!editable}
|
||||
onClick={() => openDialog({ kind: "import", id: ref.id, schema: ref.schema, table: ref.table })}
|
||||
/>
|
||||
<PanelToggle panel={panel} onChange={setPanel} />
|
||||
</div>
|
||||
<div className={styles.filterBar}>
|
||||
<label className={styles.filterField}>
|
||||
<Icon name="filter" size={14} />
|
||||
<Input
|
||||
variant="bare"
|
||||
mono
|
||||
aria-label="WHERE filter"
|
||||
placeholder="WHERE"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setOffset(0);
|
||||
setFilter(filterText.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.filterField}>
|
||||
<Icon name="db-sort" size={14} />
|
||||
<Input
|
||||
variant="bare"
|
||||
mono
|
||||
aria-label="ORDER BY"
|
||||
placeholder="ORDER BY"
|
||||
invalid={orderInvalid}
|
||||
value={orderDraft ?? formatOrderBy(sort)}
|
||||
onChange={(e) => setOrderDraft(e.target.value)}
|
||||
onBlur={() => !orderInvalid && setOrderDraft(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOrderDraft(null);
|
||||
if (e.key !== "Enter" || orderDraft === null) return;
|
||||
const next = parseOrderBy(orderDraft, names);
|
||||
if (!next) return;
|
||||
setOffset(0);
|
||||
setSort(next);
|
||||
setOrderDraft(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{page && !editable && (
|
||||
<div className={styles.notice}>
|
||||
@@ -217,16 +305,14 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
<EmptyState title="Loading…" />
|
||||
) : (
|
||||
<div className={styles.fill}>
|
||||
<Grid
|
||||
<DataView
|
||||
aria-label={`${ref.table} data`}
|
||||
columns={columns.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.typeName,
|
||||
primaryKey: pk.includes(c.name),
|
||||
foreignKey: fkColumns.has(c.name),
|
||||
}))}
|
||||
columns={gridColumns}
|
||||
rowCount={rowCount}
|
||||
fixedRows={loaded}
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
firstRowNumber={offset + 1}
|
||||
rowState={(r) => (r >= loaded ? "inserted" : edits.deletes.has(r) ? "deleted" : null)}
|
||||
isModified={(r, c) => !!edits.updates.get(r)?.has(names[c]!)}
|
||||
|
||||
@@ -45,7 +45,9 @@ import {
|
||||
cancelConsole,
|
||||
consoleSource,
|
||||
executeConsole,
|
||||
explainConsole,
|
||||
finishTransaction,
|
||||
openHistory,
|
||||
loadCompletion,
|
||||
listsDatabases,
|
||||
loadDatabases,
|
||||
@@ -94,7 +96,12 @@ const runField = StateField.define<RunState>({
|
||||
if (a <= m.to && b >= m.from) touched = true;
|
||||
});
|
||||
if (!touched)
|
||||
marks.push({ ...m, from: tr.changes.mapPos(m.from), to: tr.changes.mapPos(m.to), timingAt: tr.changes.mapPos(m.timingAt) });
|
||||
marks.push({
|
||||
...m,
|
||||
from: tr.changes.mapPos(m.from),
|
||||
to: tr.changes.mapPos(m.to),
|
||||
timingAt: tr.changes.mapPos(m.timingAt),
|
||||
});
|
||||
}
|
||||
return { ...value, marks };
|
||||
},
|
||||
@@ -102,8 +109,12 @@ const runField = StateField.define<RunState>({
|
||||
EditorView.decorations.from(f, (st) => {
|
||||
const ranges = [];
|
||||
for (const m of st.marks) {
|
||||
if (m.error) ranges.push(Decoration.mark({ class: "cm-openidea-db-error", attributes: { title: m.error } }).range(m.from, m.to));
|
||||
if (m.ms !== null) ranges.push(Decoration.widget({ widget: new TimingWidget(m.ms), side: 1 }).range(m.timingAt));
|
||||
if (m.error)
|
||||
ranges.push(
|
||||
Decoration.mark({ class: "cm-openidea-db-error", attributes: { title: m.error } }).range(m.from, m.to),
|
||||
);
|
||||
if (m.ms !== null)
|
||||
ranges.push(Decoration.widget({ widget: new TimingWidget(m.ms), side: 1 }).range(m.timingAt));
|
||||
}
|
||||
return Decoration.set(ranges, true);
|
||||
}),
|
||||
@@ -134,7 +145,11 @@ class RunMarker extends GutterMarker {
|
||||
return other.error === this.error;
|
||||
}
|
||||
toDOM() {
|
||||
const el = iconNode(this.error ? "error" : "check", 14, this.error ? "cm-openidea-db-run-error" : "cm-openidea-db-run-ok");
|
||||
const el = iconNode(
|
||||
this.error ? "error" : "check",
|
||||
14,
|
||||
this.error ? "cm-openidea-db-run-error" : "cm-openidea-db-run-ok",
|
||||
);
|
||||
el.title = this.error ?? "Executed";
|
||||
return el;
|
||||
}
|
||||
@@ -196,6 +211,12 @@ export async function executeInView(view: EditorView, path: string) {
|
||||
if (outcomes.length) markOutcomes(view, outcomes);
|
||||
}
|
||||
|
||||
/** Explain Plan of the statement at the caret (or the first selected one) of `view`. */
|
||||
export async function explainInView(view: EditorView, path: string) {
|
||||
const sel = view.state.selection.main;
|
||||
await explainConsole(path, view.state.doc.toString(), sel.from, sel.to);
|
||||
}
|
||||
|
||||
const consolePath = Facet.define<string, string>({ combine: (v) => v[0] ?? "" });
|
||||
|
||||
function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
@@ -209,7 +230,8 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
const databases = useDatabase((s) => s.databases);
|
||||
const boundId = bound ? splitTarget(bound).id : null;
|
||||
useEffect(() => {
|
||||
if (boundId && listsDatabases(sourceById(boundId)) && !useDatabase.getState().databases[boundId]) void loadDatabases(boundId);
|
||||
if (boundId && listsDatabases(sourceById(boundId)) && !useDatabase.getState().databases[boundId])
|
||||
void loadDatabases(boundId);
|
||||
}, [boundId]);
|
||||
const targets = useMemo(() => {
|
||||
const out: Array<{ value: string; label: string; detail?: string; icon: string }> = [];
|
||||
@@ -219,11 +241,17 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
const host = sourceLabel(s).slice((s.name ?? "").length + 1);
|
||||
if (listsDatabases(s) && dbs?.status === "ok") {
|
||||
for (const name of dbs.value.names)
|
||||
out.push({ value: targetOf(id, name, dbs.value.current), label: `${s.name ?? ""}: ${name}`, detail: host, icon: "database" });
|
||||
out.push({
|
||||
value: targetOf(id, name, dbs.value.current),
|
||||
label: `${s.name ?? ""}: ${name}`,
|
||||
detail: host,
|
||||
icon: "database",
|
||||
});
|
||||
} else out.push({ value: id, label: s.name ?? "", detail: host, icon: "db-schema" });
|
||||
}
|
||||
// A console bound to a database whose list is not loaded yet still shows it.
|
||||
if (bound && !out.some((o) => o.value === bound)) out.push({ value: bound, label: targetLabel(bound), icon: "database" });
|
||||
if (bound && !out.some((o) => o.value === bound))
|
||||
out.push({ value: bound, label: targetLabel(bound), icon: "database" });
|
||||
return out;
|
||||
}, [sources, databases, bound]);
|
||||
useEffect(() => {
|
||||
@@ -241,7 +269,20 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
disabled={!bound || running}
|
||||
onClick={() => void executeInView(view, path)}
|
||||
/>
|
||||
<IconButton icon="stop" title="Cancel Running Statement" disabled={!running} onClick={() => cancelConsole(path)} />
|
||||
<IconButton
|
||||
icon="db-plan"
|
||||
title="Explain Plan"
|
||||
disabled={!bound || running}
|
||||
onClick={() => void explainInView(view, path)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="stop"
|
||||
title="Cancel Running Statement"
|
||||
disabled={!running}
|
||||
onClick={() => cancelConsole(path)}
|
||||
/>
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<IconButton icon="history" title="Browse Query History" disabled={!bound} onClick={() => openHistory(path)} />
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<Select
|
||||
aria-label="Row limit"
|
||||
@@ -262,8 +303,18 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
|
||||
/>
|
||||
{manual && (
|
||||
<>
|
||||
<IconButton icon="commit" title="Commit" disabled={!pending || running} onClick={() => bound && void finishTransaction(bound, true)} />
|
||||
<IconButton icon="revert" title="Roll Back" disabled={!pending || running} onClick={() => bound && void finishTransaction(bound, false)} />
|
||||
<IconButton
|
||||
icon="commit"
|
||||
title="Commit"
|
||||
disabled={!pending || running}
|
||||
onClick={() => bound && void finishTransaction(bound, true)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="revert"
|
||||
title="Roll Back"
|
||||
disabled={!pending || running}
|
||||
onClick={() => bound && void finishTransaction(bound, false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.spacer} />
|
||||
|
||||
@@ -26,7 +26,32 @@ export interface ExportData {
|
||||
driver: DbDriver;
|
||||
}
|
||||
|
||||
export function exportData(format: ExportFormat, d: ExportData): string {
|
||||
/** The Export Data dialog's options (CSV, TSV and Markdown; JSON and SQL keep their shape). */
|
||||
export interface ExportOptions {
|
||||
columnHeader: boolean;
|
||||
rowHeader: boolean;
|
||||
transpose: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { columnHeader: true, rowHeader: false, transpose: false };
|
||||
|
||||
/** Header and rows of a delimited export after the row header / transpose options. */
|
||||
function laidOut(d: ExportData, o: ExportOptions): { head: string[] | null; rows: Cell[][] } {
|
||||
let head = d.columns.map((c) => c.name);
|
||||
let rows = d.rows.map((r) => [...r]);
|
||||
if (o.rowHeader) {
|
||||
head = ["#", ...head];
|
||||
rows = rows.map((r, i) => [String(i + 1), ...r]);
|
||||
}
|
||||
if (!o.transpose) return { head: o.columnHeader ? head : null, rows };
|
||||
return { head: null, rows: head.map((h, c) => [...(o.columnHeader ? [h] : []), ...rows.map((r) => r[c] ?? null)]) };
|
||||
}
|
||||
|
||||
export function exportData(
|
||||
format: ExportFormat,
|
||||
d: ExportData,
|
||||
options: ExportOptions = DEFAULT_EXPORT_OPTIONS,
|
||||
): string {
|
||||
const names = d.columns.map((c) => c.name);
|
||||
const numeric = d.columns.map((c, i) =>
|
||||
isNumericColumn(
|
||||
@@ -35,12 +60,17 @@ export function exportData(format: ExportFormat, d: ExportData): string {
|
||||
),
|
||||
);
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return toCsv(names, d.rows);
|
||||
case "tsv":
|
||||
return toTsv(names, d.rows);
|
||||
case "csv": {
|
||||
const t = laidOut(d, options);
|
||||
return toCsv(t.head, t.rows);
|
||||
}
|
||||
case "tsv": {
|
||||
const t = laidOut(d, options);
|
||||
return toTsv(t.head, t.rows);
|
||||
}
|
||||
case "json": {
|
||||
const value = (v: Cell, i: number) => (v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v);
|
||||
const value = (v: Cell, i: number) =>
|
||||
v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v;
|
||||
return JSON.stringify(
|
||||
d.rows.map((r) => Object.fromEntries(names.map((n, i) => [n, value(r[i] ?? null, i)]))),
|
||||
null,
|
||||
@@ -55,8 +85,15 @@ export function exportData(format: ExportFormat, d: ExportData): string {
|
||||
}
|
||||
case "markdown": {
|
||||
const cell = (v: Cell) => (v ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
||||
const align = numeric.map((n) => (n ? "--:" : "---"));
|
||||
return [names.map(cell), align, ...d.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n");
|
||||
const t = laidOut(d, options);
|
||||
const width = t.head?.length ?? t.rows[0]?.length ?? 0;
|
||||
// A Markdown table always has a header row: blank when left out.
|
||||
const head = t.head ?? Array.from({ length: width }, () => "");
|
||||
const align =
|
||||
options.transpose || t.head === null
|
||||
? head.map(() => "---")
|
||||
: [...(options.rowHeader ? ["--:"] : []), ...numeric.map((n) => (n ? "--:" : "---"))];
|
||||
return [head.map(cell), align, ...t.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
setFileDecorations,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { SQL_LANGUAGE, consoleExtension, executeInView } from "./console";
|
||||
import { SQL_LANGUAGE, consoleExtension, executeInView, explainInView } from "./console";
|
||||
import { DatabaseDialogs } from "./Dialogs";
|
||||
import { TABLE_KIND, tableTitle } from "./tableInput";
|
||||
import { nodeTarget, type DbNode } from "./nodes";
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
consoleTints,
|
||||
loadSources,
|
||||
openConsole,
|
||||
openHistory,
|
||||
openSourceDialog,
|
||||
useDatabase,
|
||||
watchIdleConnections,
|
||||
@@ -52,7 +53,12 @@ export function activate(): () => void {
|
||||
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" }),
|
||||
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.
|
||||
@@ -66,6 +72,7 @@ export function activate(): () => void {
|
||||
schemas: {},
|
||||
tables: {},
|
||||
details: {},
|
||||
objects: {},
|
||||
completion: {},
|
||||
results: [],
|
||||
output: [],
|
||||
@@ -145,6 +152,37 @@ export function activate(): () => void {
|
||||
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());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
|
||||
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSslMode, DataSource } from "@/platform/ipc";
|
||||
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSortKey, DbSslMode, DataSource } from "@/platform/ipc";
|
||||
import { vendorOf, vendorOfScheme } from "./vendors";
|
||||
|
||||
export type Cell = string | null;
|
||||
@@ -23,6 +23,74 @@ export function toCsv(header: readonly string[] | null, rows: ReadonlyArray<read
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
/** Rows of RFC 4180 text with separator `sep` (quoted fields, `""`, CR LF); a trailing newline adds no row. */
|
||||
export function parseDelimited(text: string, sep: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
let i = text.charCodeAt(0) === 0xfeff ? 1 : 0;
|
||||
for (; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quoted) {
|
||||
if (ch !== '"') field += ch;
|
||||
else if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else quoted = false;
|
||||
} else if (ch === '"' && field === "") quoted = true;
|
||||
else if (ch === sep) {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (ch === "\n" || ch === "\r") {
|
||||
if (ch === "\r" && text[i + 1] === "\n") i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
} else field += ch;
|
||||
}
|
||||
if (field !== "" || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The separator of delimited text: tab when its first line has more tabs than commas or semicolons. */
|
||||
export function guessSeparator(text: string): string {
|
||||
const first = text.slice(0, text.search(/\r?\n|$/));
|
||||
const count = (c: string) => first.split(c).length - 1;
|
||||
return [",", "\t", ";"].reduce((best, c) => (count(c) > count(best) ? c : best), ",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert edits importing `rows` into a table with `columns`: by header name
|
||||
* (case-insensitive; unknown names skipped) or by position. `emptyAsNull`
|
||||
* turns empty fields into NULL.
|
||||
*/
|
||||
export function importEdits(
|
||||
rows: readonly string[][],
|
||||
columns: readonly string[],
|
||||
header: boolean,
|
||||
emptyAsNull: boolean,
|
||||
): { edits: DbRowEdit[]; mapped: string[] } {
|
||||
const target = (h: string) => columns.find((c) => c.toLowerCase() === h.trim().toLowerCase()) ?? null;
|
||||
const map: Array<string | null> = header
|
||||
? (rows[0] ?? []).map(target)
|
||||
: columns.slice(0, Math.max(0, ...rows.map((r) => r.length)));
|
||||
const body = header ? rows.slice(1) : rows;
|
||||
const edits: DbRowEdit[] = body
|
||||
.filter((r) => r.some((v) => v !== ""))
|
||||
.map((r) => ({
|
||||
kind: "insert",
|
||||
values: map.flatMap((column, i) =>
|
||||
column === null ? [] : [{ column, value: emptyAsNull && (r[i] ?? "") === "" ? null : (r[i] ?? null) }],
|
||||
),
|
||||
}));
|
||||
return { edits, mapped: map.filter((c): c is string => c !== null) };
|
||||
}
|
||||
|
||||
/** Pending changes of the table editor. Row indices refer to the loaded page. */
|
||||
export interface PendingEdits {
|
||||
/** row → column → new value. */
|
||||
@@ -53,7 +121,8 @@ export function buildRowEdits(
|
||||
out.push({ kind: "update", key: keyOf(row), values: [...changes].map(([column, value]) => ({ column, value })) });
|
||||
}
|
||||
for (const row of [...edits.deletes].sort((a, b) => a - b)) out.push({ kind: "delete", key: keyOf(row) });
|
||||
for (const ins of edits.inserts) out.push({ kind: "insert", values: [...ins].map(([column, value]) => ({ column, value })) });
|
||||
for (const ins of edits.inserts)
|
||||
out.push({ kind: "insert", values: [...ins].map(([column, value]) => ({ column, value })) });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -80,15 +149,49 @@ export function colorVar(color: DbColor | null | undefined): string | undefined
|
||||
/** `*` / `?` wildcard match of the Schemas tab (case-sensitive, like the server's names). */
|
||||
export function matchesPattern(name: string, pattern: string): boolean {
|
||||
if (!/[*?]/.test(pattern)) return name === pattern;
|
||||
const re = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
||||
const re = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${re}$`).test(name);
|
||||
}
|
||||
|
||||
/** The table editor's ORDER BY field for a sort: `name DESC, id`. */
|
||||
export function formatOrderBy(sort: readonly DbSortKey[]): string {
|
||||
return sort.map((k) => (k.descending ? `${k.column} DESC` : k.column)).join(", ");
|
||||
}
|
||||
|
||||
/** Parses the ORDER BY field (`col [ASC|DESC], …`, names matched case-insensitively); null when a part isn't a column. */
|
||||
export function parseOrderBy(text: string, columns: readonly string[]): DbSortKey[] | null {
|
||||
const out: DbSortKey[] = [];
|
||||
for (const part of text
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)) {
|
||||
const m = /^("?)([^"]+?)\1(?:\s+(asc|desc))?$/i.exec(part);
|
||||
const column = m && columns.find((c) => c.toLowerCase() === m[2]!.trim().toLowerCase());
|
||||
if (!column) return null;
|
||||
out.push({ column, descending: m[3]?.toLowerCase() === "desc" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Schemas tab › Object filter: `name` matches one of its patterns (comma / space separated; empty: all). */
|
||||
export function passesObjectFilter(name: string, filter: string | null | undefined): boolean {
|
||||
const patterns = (filter ?? "").split(/[\s,]+/).filter(Boolean);
|
||||
return !patterns.length || patterns.some((p) => matchesPattern(name.toLowerCase(), p.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The databases / schemas the tree shows (Schemas tab): all of them, or the
|
||||
* ones matching a chosen name or pattern; `keep` (the connected database) always stays.
|
||||
*/
|
||||
export function shownNames(names: readonly string[], all: boolean | undefined, chosen: readonly string[] | undefined, keep?: string | null): string[] {
|
||||
export function shownNames(
|
||||
names: readonly string[],
|
||||
all: boolean | undefined,
|
||||
chosen: readonly string[] | undefined,
|
||||
keep?: string | null,
|
||||
): string[] {
|
||||
if (all !== false) return [...names];
|
||||
const picks = (chosen ?? []).map((p) => p.trim()).filter(Boolean);
|
||||
return names.filter((n) => n === keep || picks.some((p) => matchesPattern(n, p)));
|
||||
@@ -115,7 +218,8 @@ export function idleTargets(
|
||||
}
|
||||
|
||||
export function newSourceId(): string {
|
||||
const rnd = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
const rnd =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
return rnd.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 36);
|
||||
}
|
||||
|
||||
@@ -127,7 +231,11 @@ export type ConnectRemedy = "password" | "sshSecret" | "trustHostKey" | null;
|
||||
* or rejected SSH password / passphrase, or a database password. A changed host
|
||||
* key is never offered for trust.
|
||||
*/
|
||||
export function connectRemedy(kind: string | undefined, message: string, source?: Pick<DataSource, "ssh">): ConnectRemedy {
|
||||
export function connectRemedy(
|
||||
kind: string | undefined,
|
||||
message: string,
|
||||
source?: Pick<DataSource, "ssh">,
|
||||
): ConnectRemedy {
|
||||
if (/^Unknown SSH host key/.test(message)) return "trustHostKey";
|
||||
if (kind !== "permissionDenied") return null;
|
||||
if (/^SSH host key .* has changed/.test(message)) return null;
|
||||
@@ -189,7 +297,10 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
|
||||
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
|
||||
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
|
||||
}
|
||||
const m = /^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
|
||||
const m =
|
||||
/^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(
|
||||
url,
|
||||
);
|
||||
const vendor = m ? vendorOfScheme(m[1]!) : undefined;
|
||||
if (!m || !vendor || vendor.driver === "sqlite") return null;
|
||||
const dec = (s: string | undefined) => {
|
||||
@@ -204,7 +315,13 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
|
||||
let user = dec(m[2]);
|
||||
let password = m[3] !== undefined ? dec(m[3]) : null;
|
||||
const host = m[4]!.replace(/^\[(.*)\]$/, "$1");
|
||||
const source: Partial<DataSource> = { driver: vendor.driver, host, port, user, database: dec(m[6]).replace(/\/$/, "") };
|
||||
const source: Partial<DataSource> = {
|
||||
driver: vendor.driver,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
database: dec(m[6]).replace(/\/$/, ""),
|
||||
};
|
||||
if (vendor.id !== vendor.driver) source.vendor = vendor.id;
|
||||
const properties: DbProperty[] = [];
|
||||
for (const [k, v] of new URLSearchParams(m[7] ?? "")) {
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
/** Nodes of the Database tool window tree and the objects they stand for. */
|
||||
import type { DataSource, DbColumnInfo, DbForeignKey, DbIndexInfo, DbTableInfo } from "@/platform/ipc";
|
||||
import type {
|
||||
DataSource,
|
||||
DbColumnInfo,
|
||||
DbForeignKey,
|
||||
DbIndexInfo,
|
||||
DbObjectKind as DbSchemaObjectKind,
|
||||
DbRoutineInfo,
|
||||
DbSequenceInfo,
|
||||
DbTableInfo,
|
||||
DbTableKind,
|
||||
DbTriggerInfo,
|
||||
} from "@/platform/ipc";
|
||||
import type { DbObjectRef } from "./objects";
|
||||
|
||||
export type Group = "columns" | "keys" | "foreignKeys" | "indexes";
|
||||
export type Group = "columns" | "keys" | "foreignKeys" | "indexes" | "triggers";
|
||||
|
||||
/** Folders under a schema: IDEA's `tables`, `views`, `materialized views`, `routines`, `sequences`. */
|
||||
export type Folder = "tables" | "views" | "materializedViews" | "routines" | "sequences";
|
||||
|
||||
/** The folder a table, view or materialized view is listed in. */
|
||||
export function tableFolder(kind: DbTableKind): Folder {
|
||||
return kind === "view" ? "views" : kind === "materializedView" ? "materializedViews" : "tables";
|
||||
}
|
||||
|
||||
/** Where a table-level node lives: connection target, schema, table. */
|
||||
interface InTable {
|
||||
@@ -16,13 +35,16 @@ export type DbNode =
|
||||
/** A database of a PostgreSQL server; `id` is its connection target. */
|
||||
| { kind: "database"; key: string; id: string; name: string; current: boolean }
|
||||
| { kind: "schema"; key: string; id: string; schema: string }
|
||||
| { kind: "folder"; key: string; id: string; schema: string; folder: "tables" | "views" }
|
||||
| { kind: "folder"; key: string; id: string; schema: string; folder: Folder }
|
||||
| { kind: "table"; key: string; id: string; schema: string; table: DbTableInfo }
|
||||
| { kind: "routine"; key: string; id: string; schema: string; routine: DbRoutineInfo }
|
||||
| { kind: "sequence"; key: string; id: string; schema: string; sequence: DbSequenceInfo }
|
||||
| ({ kind: "group"; key: string; group: Group } & InTable)
|
||||
| ({ kind: "column"; key: string; column: DbColumnInfo; foreignKey: boolean } & InTable)
|
||||
| ({ kind: "key"; key: string; columns: string[] } & InTable)
|
||||
| ({ kind: "foreignKey"; key: string; fk: DbForeignKey } & InTable)
|
||||
| ({ kind: "index"; key: string; index: DbIndexInfo } & InTable)
|
||||
| ({ kind: "trigger"; key: string; trigger: DbTriggerInfo } & InTable)
|
||||
| { kind: "message"; key: string; text: string; error?: boolean };
|
||||
|
||||
/** The connection target a node belongs to. */
|
||||
@@ -33,9 +55,23 @@ export function nodeTarget(n: DbNode): string | null {
|
||||
}
|
||||
|
||||
/** The table (or view) a node is, or is inside of. */
|
||||
export function tableOf(n: DbNode): { id: string; schema: string; table: string; view: boolean } | null {
|
||||
if (n.kind === "table") return { id: n.id, schema: n.schema, table: n.table.name, view: n.table.kind === "view" };
|
||||
if ("table" in n && typeof n.table === "string") return { id: n.id, schema: n.schema, table: n.table, view: false };
|
||||
export function tableOf(
|
||||
n: DbNode,
|
||||
): { id: string; schema: string; table: string; view: boolean; kind: DbTableKind } | null {
|
||||
if (n.kind === "table")
|
||||
return { id: n.id, schema: n.schema, table: n.table.name, view: n.table.kind !== "table", kind: n.table.kind };
|
||||
if ("table" in n && typeof n.table === "string")
|
||||
return { id: n.id, schema: n.schema, table: n.table, view: false, kind: "table" };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A routine, trigger or sequence node: what Go to DDL / Quick Documentation read. */
|
||||
export function schemaObjectOf(
|
||||
n: DbNode,
|
||||
): { id: string; schema: string; kind: DbSchemaObjectKind; name: string } | null {
|
||||
if (n.kind === "routine") return { id: n.id, schema: n.schema, kind: n.routine.kind, name: n.routine.name };
|
||||
if (n.kind === "sequence") return { id: n.id, schema: n.schema, kind: "sequence", name: n.sequence.name };
|
||||
if (n.kind === "trigger") return { id: n.id, schema: n.schema, kind: "trigger", name: n.trigger.name };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,11 +81,17 @@ export function objectOf(n: DbNode): DbObjectRef | null {
|
||||
case "schema":
|
||||
return { kind: "schema", schema: n.schema, name: n.schema };
|
||||
case "table":
|
||||
return { kind: n.table.kind === "view" ? "view" : "table", schema: n.schema, name: n.table.name };
|
||||
return { kind: n.table.kind, schema: n.schema, name: n.table.name };
|
||||
case "column":
|
||||
return { kind: "column", schema: n.schema, name: n.table, column: n.column.name };
|
||||
case "index":
|
||||
return { kind: "index", schema: n.schema, name: n.index.name, table: n.table };
|
||||
case "routine":
|
||||
return { kind: n.routine.kind, schema: n.schema, name: n.routine.name, arguments: n.routine.arguments };
|
||||
case "sequence":
|
||||
return { kind: "sequence", schema: n.schema, name: n.sequence.name };
|
||||
case "trigger":
|
||||
return { kind: "trigger", schema: n.schema, name: n.trigger.name, table: n.table };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -30,18 +30,30 @@ export function qualifiedName(driver: DbDriver, schema: string, name: string): s
|
||||
return `${ident(driver, schema)}.${ident(driver, name)}`;
|
||||
}
|
||||
|
||||
export type DbObjectKind = "table" | "view" | "column" | "index" | "schema";
|
||||
export type DbObjectKind =
|
||||
| "table"
|
||||
| "view"
|
||||
| "materializedView"
|
||||
| "column"
|
||||
| "index"
|
||||
| "schema"
|
||||
| "function"
|
||||
| "procedure"
|
||||
| "trigger"
|
||||
| "sequence";
|
||||
|
||||
/** A schema object of a data source, as the tree actions see it. */
|
||||
export interface DbObjectRef {
|
||||
kind: DbObjectKind;
|
||||
schema: string;
|
||||
/** Table / view / index / schema name; the column's table for a column. */
|
||||
/** Table / view / index / schema / routine / trigger / sequence name; the column's table for a column. */
|
||||
name: string;
|
||||
/** Column name (kind `column`). */
|
||||
column?: string;
|
||||
/** Table of an index (MySQL drops an index `ON` its table). */
|
||||
/** Table of an index or trigger (MySQL drops an index `ON` its table, PostgreSQL a trigger). */
|
||||
table?: string;
|
||||
/** Argument types of a PostgreSQL routine (tells overloads apart). */
|
||||
arguments?: string;
|
||||
}
|
||||
|
||||
/** IDEA's Copy Reference: the object's qualified name. */
|
||||
@@ -52,7 +64,10 @@ export function reference(driver: DbDriver, o: DbObjectRef): string {
|
||||
}
|
||||
|
||||
export const canRename = (driver: DbDriver, o: DbObjectRef) =>
|
||||
o.kind === "table" || o.kind === "column" || (o.kind === "view" && driver !== "sqlite") || (o.kind === "index" && driver === "postgres");
|
||||
o.kind === "table" ||
|
||||
o.kind === "column" ||
|
||||
(o.kind === "view" && driver !== "sqlite") ||
|
||||
(o.kind === "index" && driver === "postgres");
|
||||
|
||||
/** The statement renaming `o` to `to`. */
|
||||
export function renameSql(driver: DbDriver, o: DbObjectRef, to: string): string {
|
||||
@@ -87,17 +102,34 @@ export function dropSql(driver: DbDriver, o: DbObjectRef, opts: DropOptions): st
|
||||
case "column":
|
||||
return `alter table ${qualifiedName(driver, o.schema, o.name)} drop column ${ifExists && driver === "postgres" ? ifExists : ""}${ident(driver, o.column ?? "")}${cascade};`;
|
||||
case "index":
|
||||
if (driver === "mysql") return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
|
||||
if (driver === "mysql")
|
||||
return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
|
||||
return `drop index ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
|
||||
case "schema":
|
||||
return `drop ${driver === "mysql" ? "database" : "schema"} ${ifExists}${ident(driver, o.name)}${cascade};`;
|
||||
case "materializedView":
|
||||
return `drop materialized view ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
|
||||
case "function":
|
||||
case "procedure": {
|
||||
const args = driver === "postgres" ? `(${o.arguments ?? ""})` : "";
|
||||
return `drop ${o.kind} ${ifExists}${qualifiedName(driver, o.schema, o.name)}${args}${cascade};`;
|
||||
}
|
||||
case "trigger":
|
||||
if (driver === "postgres")
|
||||
return `drop trigger ${ifExists}${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")}${cascade};`;
|
||||
return `drop trigger ${ifExists}${qualifiedName(driver, o.schema, o.name)};`;
|
||||
default:
|
||||
return `drop ${o.kind} ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Object Actions › Truncate (SQLite has no TRUNCATE: `delete from`). */
|
||||
export function truncateSql(driver: DbDriver, schema: string, table: string, opts: { restartIdentity: boolean; cascade: boolean }): string {
|
||||
export function truncateSql(
|
||||
driver: DbDriver,
|
||||
schema: string,
|
||||
table: string,
|
||||
opts: { restartIdentity: boolean; cascade: boolean },
|
||||
): string {
|
||||
const q = qualifiedName(driver, schema, table);
|
||||
if (driver === "sqlite") return `delete from ${q};`;
|
||||
if (driver === "mysql") return `truncate table ${q};`;
|
||||
@@ -115,7 +147,13 @@ export const SCRIPT_LABELS: Record<ScriptKind, string> = {
|
||||
};
|
||||
|
||||
/** SQL Scripts › … templates (IDEA's live templates `sel`, `selc`, `ins`, `upd`, `del`). */
|
||||
export function scriptSql(driver: DbDriver, kind: ScriptKind, schema: string, table: string, columns: readonly DbColumnInfo[]): string {
|
||||
export function scriptSql(
|
||||
driver: DbDriver,
|
||||
kind: ScriptKind,
|
||||
schema: string,
|
||||
table: string,
|
||||
columns: readonly DbColumnInfo[],
|
||||
): string {
|
||||
const q = qualifiedName(driver, schema, table);
|
||||
const names = columns.map((c) => ident(driver, c.name));
|
||||
const keys = columns.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
|
||||
@@ -172,7 +210,12 @@ function columnDefinition(driver: DbDriver, c: ColumnDraft): string {
|
||||
}
|
||||
|
||||
/** `create table` for New › Table. */
|
||||
export function createTableSql(driver: DbDriver, schema: string, name: string, columns: readonly ColumnDraft[]): string {
|
||||
export function createTableSql(
|
||||
driver: DbDriver,
|
||||
schema: string,
|
||||
name: string,
|
||||
columns: readonly ColumnDraft[],
|
||||
): string {
|
||||
const live = columns.filter((c) => !c.dropped && c.name.trim());
|
||||
const keys = live.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
|
||||
const items = live.map((c) => ` ${columnDefinition(driver, c)}`);
|
||||
@@ -190,7 +233,13 @@ export function canAlterColumn(driver: DbDriver, c: ColumnDraft): boolean {
|
||||
* (Modify Table): renames, added / dropped columns, and — PostgreSQL / MySQL —
|
||||
* type, nullability and default changes.
|
||||
*/
|
||||
export function alterTableSql(driver: DbDriver, schema: string, table: string, name: string, columns: readonly ColumnDraft[]): string[] {
|
||||
export function alterTableSql(
|
||||
driver: DbDriver,
|
||||
schema: string,
|
||||
table: string,
|
||||
name: string,
|
||||
columns: readonly ColumnDraft[],
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const q = () => qualifiedName(driver, schema, table);
|
||||
for (const c of columns) {
|
||||
@@ -215,11 +264,15 @@ export function alterTableSql(driver: DbDriver, schema: string, table: string, n
|
||||
const col = ident(driver, renamed ? c.name : o.name);
|
||||
if (c.dataType.trim() && c.dataType.trim() !== o.dataType)
|
||||
out.push(`alter table ${q()} alter column ${col} type ${c.dataType.trim()};`);
|
||||
if (c.notNull !== !o.nullable) out.push(`alter table ${q()} alter column ${col} ${c.notNull ? "set" : "drop"} not null;`);
|
||||
if (c.notNull !== !o.nullable)
|
||||
out.push(`alter table ${q()} alter column ${col} ${c.notNull ? "set" : "drop"} not null;`);
|
||||
if (c.default.trim() !== (o.default ?? "").trim())
|
||||
out.push(`alter table ${q()} alter column ${col} ${c.default.trim() ? `set default ${c.default.trim()}` : "drop default"};`);
|
||||
out.push(
|
||||
`alter table ${q()} alter column ${col} ${c.default.trim() ? `set default ${c.default.trim()}` : "drop default"};`,
|
||||
);
|
||||
}
|
||||
// Renamed last: the column statements above name the table as it was.
|
||||
if (name.trim() && name.trim() !== table) out.push(renameSql(driver, { kind: "table", schema, name: table }, name.trim()));
|
||||
if (name.trim() && name.trim() !== table)
|
||||
out.push(renameSql(driver, { kind: "table", schema, name: table }, name.trim()));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { exportData, type ExportData } from "./export";
|
||||
import {
|
||||
formatOrderBy,
|
||||
guessSeparator,
|
||||
importEdits,
|
||||
parseDelimited,
|
||||
parseOrderBy,
|
||||
passesObjectFilter,
|
||||
type Cell,
|
||||
} from "./model";
|
||||
import { dropSql } from "./objects";
|
||||
import {
|
||||
aggregates,
|
||||
chartSeries,
|
||||
compareCells,
|
||||
distinctValues,
|
||||
niceTicks,
|
||||
toggleFilterValue,
|
||||
visibleRows,
|
||||
type LocalFilter,
|
||||
} from "./resultView";
|
||||
|
||||
const rows: Cell[][] = [
|
||||
["1", "Shanghai", "10"],
|
||||
["2", "Beijing", null],
|
||||
["3", "Shanghai", "2.5"],
|
||||
["4", null, "7"],
|
||||
];
|
||||
const value = (r: number, c: number) => rows[r]?.[c] ?? null;
|
||||
|
||||
describe("local filter and sort", () => {
|
||||
it("compares numbers by value, NULL first", () => {
|
||||
expect(["10", "9", null, "abc"].sort(compareCells)).toEqual([null, "9", "10", "abc"]);
|
||||
});
|
||||
|
||||
it("keeps the rows whose values are checked, then sorts them", () => {
|
||||
const filter: LocalFilter = new Map([[1, new Set<Cell>(["Shanghai", null])]]);
|
||||
expect(visibleRows(4, value, filter, null)).toEqual([0, 2, 3]);
|
||||
expect(visibleRows(4, value, filter, { column: 2, descending: true })).toEqual([0, 3, 2]);
|
||||
expect(visibleRows(4, value, new Map(), { column: 2, descending: false })).toEqual([1, 2, 3, 0]);
|
||||
});
|
||||
|
||||
it("leaves rows past `fixed` (added rows) last and unfiltered", () => {
|
||||
const filter: LocalFilter = new Map([[1, new Set<Cell>(["Beijing"])]]);
|
||||
expect(visibleRows(4, value, filter, null, 3)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("lists distinct values with counts and toggles them", () => {
|
||||
const d = distinctValues(4, value, 1);
|
||||
expect(d).toEqual([
|
||||
{ value: null, count: 1 },
|
||||
{ value: "Beijing", count: 1 },
|
||||
{ value: "Shanghai", count: 2 },
|
||||
]);
|
||||
const all = d.map((x) => x.value);
|
||||
let f = toggleFilterValue(new Map(), 1, all, "Beijing", false);
|
||||
expect([...f.get(1)!]).toEqual([null, "Shanghai"]);
|
||||
f = toggleFilterValue(f, 1, all, "Beijing", true);
|
||||
expect(f.has(1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregates", () => {
|
||||
it("sums numbers and reports text min / max", () => {
|
||||
expect(aggregates(["10", null, "2.5", "7"])).toMatchObject({
|
||||
count: 4,
|
||||
distinct: 4,
|
||||
nulls: 1,
|
||||
sum: 19.5,
|
||||
avg: 6.5,
|
||||
median: 7,
|
||||
min: "2.5",
|
||||
max: "10",
|
||||
});
|
||||
expect(aggregates(["b", "a", "b"])).toMatchObject({ distinct: 2, sum: null, avg: null, min: "a", max: "b" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("chart series", () => {
|
||||
it("combines Y per X", () => {
|
||||
expect(chartSeries(rows, 1, 2, "sum")).toEqual([
|
||||
{ label: "Shanghai", value: 12.5 },
|
||||
{ label: "Beijing", value: 0 },
|
||||
{ label: "<null>", value: 7 },
|
||||
]);
|
||||
expect(chartSeries(rows, 1, 2, "count").map((p) => p.value)).toEqual([2, 1, 1]);
|
||||
expect(chartSeries(rows, 0, 2, "none").map((p) => p.label)).toEqual(["1", "3", "4"]);
|
||||
});
|
||||
|
||||
it("picks round ticks from zero", () => {
|
||||
expect(niceTicks(0, 3100)).toEqual([0, 1000, 2000, 3000, 4000]);
|
||||
expect(niceTicks(-5, 5)).toEqual([-6, -4, -2, 0, 2, 4, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export options", () => {
|
||||
const data: ExportData = {
|
||||
columns: [
|
||||
{ name: "id", type: "INTEGER" },
|
||||
{ name: "city", type: "TEXT" },
|
||||
],
|
||||
rows: [
|
||||
["1", "Shanghai"],
|
||||
["2", null],
|
||||
],
|
||||
table: "customers",
|
||||
driver: "sqlite",
|
||||
};
|
||||
|
||||
it("leaves the header out, adds row numbers and transposes", () => {
|
||||
const opts = { columnHeader: false, rowHeader: false, transpose: false };
|
||||
expect(exportData("csv", data, opts)).toBe("1,Shanghai\r\n2,");
|
||||
expect(exportData("csv", data, { ...opts, rowHeader: true, columnHeader: true })).toBe(
|
||||
"#,id,city\r\n1,1,Shanghai\r\n2,2,",
|
||||
);
|
||||
expect(exportData("tsv", data, { ...opts, columnHeader: true, transpose: true })).toBe(
|
||||
"id\t1\t2\ncity\tShanghai\t",
|
||||
);
|
||||
expect(exportData("csv", data)).toBe("id,city\r\n1,Shanghai\r\n2,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("import from file", () => {
|
||||
it("parses quoted CSV and guesses the separator", () => {
|
||||
expect(parseDelimited('a,"b ""q""",c\r\n1,"x,y",\n', ",")).toEqual([
|
||||
["a", 'b "q"', "c"],
|
||||
["1", "x,y", ""],
|
||||
]);
|
||||
expect(guessSeparator("a\tb\tc\n1\t2\t3")).toBe("\t");
|
||||
expect(guessSeparator("a;b;c")).toBe(";");
|
||||
});
|
||||
|
||||
it("maps columns by header name or position", () => {
|
||||
const parsed = [
|
||||
["CITY", "nope", "id"],
|
||||
["Shanghai", "x", "1"],
|
||||
["", "", ""],
|
||||
["", "y", "2"],
|
||||
];
|
||||
const byName = importEdits(parsed, ["id", "city"], true, true);
|
||||
expect(byName.mapped).toEqual(["city", "id"]);
|
||||
expect(byName.edits).toEqual([
|
||||
{
|
||||
kind: "insert",
|
||||
values: [
|
||||
{ column: "city", value: "Shanghai" },
|
||||
{ column: "id", value: "1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "insert",
|
||||
values: [
|
||||
{ column: "city", value: null },
|
||||
{ column: "id", value: "2" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const byPosition = importEdits([["1", "a"]], ["id", "city", "extra"], false, false);
|
||||
expect(byPosition.edits).toEqual([
|
||||
{
|
||||
kind: "insert",
|
||||
values: [
|
||||
{ column: "id", value: "1" },
|
||||
{ column: "city", value: "a" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ORDER BY field", () => {
|
||||
it("round-trips the sort and rejects unknown columns", () => {
|
||||
const cols = ["id", "Name"];
|
||||
expect(parseOrderBy('name desc, "id" ASC', cols)).toEqual([
|
||||
{ column: "Name", descending: true },
|
||||
{ column: "id", descending: false },
|
||||
]);
|
||||
expect(
|
||||
formatOrderBy([
|
||||
{ column: "Name", descending: true },
|
||||
{ column: "id", descending: false },
|
||||
]),
|
||||
).toBe("Name DESC, id");
|
||||
expect(parseOrderBy("", cols)).toEqual([]);
|
||||
expect(parseOrderBy("nope", cols)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("object filter", () => {
|
||||
it("matches any of its patterns, ignoring case", () => {
|
||||
expect(passesObjectFilter("orders", "")).toBe(true);
|
||||
expect(passesObjectFilter("Order_Items", "order*, customers")).toBe(true);
|
||||
expect(passesObjectFilter("customers", "order* customers")).toBe(true);
|
||||
expect(passesObjectFilter("products", "order*,customers")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropping schema objects", () => {
|
||||
const opts = { ifExists: true, cascade: false };
|
||||
it("drops routines, triggers, sequences and materialized views per dialect", () => {
|
||||
expect(dropSql("postgres", { kind: "function", schema: "public", name: "f", arguments: "a integer" }, opts)).toBe(
|
||||
"drop function if exists public.f(a integer);",
|
||||
);
|
||||
expect(dropSql("mysql", { kind: "procedure", schema: "db", name: "p" }, opts)).toBe(
|
||||
"drop procedure if exists db.p;",
|
||||
);
|
||||
expect(dropSql("postgres", { kind: "trigger", schema: "s", name: "t", table: "x" }, opts)).toBe(
|
||||
"drop trigger if exists t on s.x;",
|
||||
);
|
||||
expect(dropSql("sqlite", { kind: "trigger", schema: "main", name: "t", table: "x" }, opts)).toBe(
|
||||
"drop trigger if exists t;",
|
||||
);
|
||||
expect(dropSql("postgres", { kind: "sequence", schema: "s", name: "q" }, opts)).toBe(
|
||||
"drop sequence if exists s.q;",
|
||||
);
|
||||
expect(dropSql("postgres", { kind: "materializedView", schema: "s", name: "m" }, opts)).toBe(
|
||||
"drop materialized view if exists s.m;",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Pure helpers of the result views: IDEA's local filter and client-side sort
|
||||
* of loaded rows, the Aggregates of a selection, and the series of the chart.
|
||||
*/
|
||||
import type { Cell } from "./model";
|
||||
|
||||
/** Column index → the values still shown (IDEA's local filter: checked values). */
|
||||
export type LocalFilter = ReadonlyMap<number, ReadonlySet<Cell>>;
|
||||
|
||||
export interface LocalSort {
|
||||
column: number;
|
||||
descending: boolean;
|
||||
}
|
||||
|
||||
const NUMBER = /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i;
|
||||
|
||||
/** Numbers by value, then text; NULL first (as most databases sort ascending). */
|
||||
export function compareCells(a: Cell, b: Cell): number {
|
||||
if (a === b) return 0;
|
||||
if (a === null) return -1;
|
||||
if (b === null) return 1;
|
||||
if (NUMBER.test(a) && NUMBER.test(b)) return Number(a) - Number(b);
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loaded rows to show, in order: those passing every column filter,
|
||||
* sorted by `sort` (stable, original order otherwise). Rows past `fixed`
|
||||
* (the table editor's added rows) always stay, last.
|
||||
*/
|
||||
export function visibleRows(
|
||||
rowCount: number,
|
||||
value: (row: number, col: number) => Cell,
|
||||
filter: LocalFilter,
|
||||
sort: LocalSort | null,
|
||||
fixed = rowCount,
|
||||
): number[] {
|
||||
const rows: number[] = [];
|
||||
for (let r = 0; r < Math.min(rowCount, fixed); r++) {
|
||||
let shown = true;
|
||||
for (const [col, allowed] of filter) {
|
||||
if (!allowed.has(value(r, col))) {
|
||||
shown = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shown) rows.push(r);
|
||||
}
|
||||
if (sort) {
|
||||
const dir = sort.descending ? -1 : 1;
|
||||
rows.sort((a, b) => dir * compareCells(value(a, sort.column), value(b, sort.column)) || a - b);
|
||||
}
|
||||
for (let r = fixed; r < rowCount; r++) rows.push(r);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Distinct values of a column with their counts, sorted (the filter popup's list). */
|
||||
export function distinctValues(
|
||||
rowCount: number,
|
||||
value: (row: number, col: number) => Cell,
|
||||
col: number,
|
||||
limit = 1000,
|
||||
): Array<{ value: Cell; count: number }> {
|
||||
const counts = new Map<Cell, number>();
|
||||
for (let r = 0; r < rowCount; r++) {
|
||||
const v = value(r, col);
|
||||
counts.set(v, (counts.get(v) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.map(([v, count]) => ({ value: v, count }))
|
||||
.sort((a, b) => compareCells(a.value, b.value))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** The new filter after toggling `v` of column `col` (all checked again: no filter). */
|
||||
export function toggleFilterValue(
|
||||
filter: LocalFilter,
|
||||
col: number,
|
||||
all: readonly Cell[],
|
||||
v: Cell,
|
||||
checked: boolean,
|
||||
): LocalFilter {
|
||||
const next = new Map(filter);
|
||||
const current = new Set(filter.get(col) ?? all);
|
||||
if (checked) current.add(v);
|
||||
else current.delete(v);
|
||||
if (all.every((x) => current.has(x))) next.delete(col);
|
||||
else next.set(col, current);
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface Aggregates {
|
||||
count: number;
|
||||
distinct: number;
|
||||
nulls: number;
|
||||
/** When every non-NULL value is a number; null otherwise. */
|
||||
sum: number | null;
|
||||
avg: number | null;
|
||||
min: Cell;
|
||||
max: Cell;
|
||||
median: number | null;
|
||||
}
|
||||
|
||||
/** IDEA's Aggregates view of the selected cells. */
|
||||
export function aggregates(cells: readonly Cell[]): Aggregates {
|
||||
const present = cells.filter((c): c is string => c !== null);
|
||||
const numbers = present.filter((c) => NUMBER.test(c)).map(Number);
|
||||
const numeric = numbers.length > 0 && numbers.length === present.length;
|
||||
const sorted = [...present].sort(compareCells);
|
||||
let median: number | null = null;
|
||||
if (numeric) {
|
||||
const n = [...numbers].sort((a, b) => a - b);
|
||||
const mid = Math.floor(n.length / 2);
|
||||
median = n.length % 2 ? n[mid]! : (n[mid - 1]! + n[mid]!) / 2;
|
||||
}
|
||||
const sum = numeric ? numbers.reduce((a, b) => a + b, 0) : null;
|
||||
return {
|
||||
count: cells.length,
|
||||
distinct: new Set(cells).size,
|
||||
nulls: cells.length - present.length,
|
||||
sum,
|
||||
avg: sum === null ? null : sum / numbers.length,
|
||||
min: sorted[0] ?? null,
|
||||
max: sorted.at(-1) ?? null,
|
||||
median,
|
||||
};
|
||||
}
|
||||
|
||||
/** A number for display: integers as is, others to at most 6 significant decimals. */
|
||||
export function formatNumber(n: number): string {
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return String(Number(n.toPrecision(12)));
|
||||
}
|
||||
|
||||
export type ChartAggregate = "sum" | "avg" | "count" | "min" | "max" | "none";
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart's series: `y` per distinct `x` (in first-seen order), combined with
|
||||
* `agg`; `none` plots every row as is. Non-numeric `y` values are skipped
|
||||
* (except for `count`).
|
||||
*/
|
||||
export function chartSeries(
|
||||
rows: ReadonlyArray<readonly Cell[]>,
|
||||
x: number,
|
||||
y: number,
|
||||
agg: ChartAggregate,
|
||||
): ChartPoint[] {
|
||||
if (agg === "none")
|
||||
return rows
|
||||
.filter((r) => r[y] !== null && NUMBER.test(r[y]!))
|
||||
.map((r) => ({ label: r[x] ?? "<null>", value: Number(r[y]) }));
|
||||
const groups = new Map<string, number[]>();
|
||||
for (const r of rows) {
|
||||
const label = r[x] ?? "<null>";
|
||||
const v = r[y] ?? null;
|
||||
if (!groups.has(label)) groups.set(label, []);
|
||||
if (agg === "count") groups.get(label)!.push(1);
|
||||
else if (v !== null && NUMBER.test(v)) groups.get(label)!.push(Number(v));
|
||||
}
|
||||
const combine = (v: number[]): number => {
|
||||
if (!v.length) return 0;
|
||||
switch (agg) {
|
||||
case "sum":
|
||||
case "count":
|
||||
return v.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return v.reduce((a, b) => a + b, 0) / v.length;
|
||||
case "min":
|
||||
return Math.min(...v);
|
||||
case "max":
|
||||
return Math.max(...v);
|
||||
}
|
||||
};
|
||||
return [...groups].map(([label, v]) => ({ label, value: combine(v) }));
|
||||
}
|
||||
|
||||
/** Round axis ticks from 0 (or the minimum, when negative) to `max`. */
|
||||
export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
const lo = Math.min(0, min);
|
||||
const hi = Math.max(0, max);
|
||||
if (hi === lo) return [lo];
|
||||
const raw = (hi - lo) / count;
|
||||
const mag = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
|
||||
const out: number[] = [];
|
||||
for (let t = Math.floor(lo / step) * step; t <= hi + step * 1e-9; t += step) out.push(Number(t.toPrecision(12)));
|
||||
if (out.at(-1)! < hi) out.push(Number((out.at(-1)! + step).toPrecision(12)));
|
||||
return out;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
eq(t.schema, defaultSchema) || driver === "sqlite" ? q(t.name) : `${q(t.schema)}.${q(t.name)}`;
|
||||
const tableCandidate = (t: DbCompletionTable, boost: number, qualified = false): SqlCandidate => ({
|
||||
label: t.name,
|
||||
kind: t.kind === "view" ? "view" : "table",
|
||||
kind: t.kind !== "table" ? "view" : "table",
|
||||
tail: t.schema ? `(${t.schema})` : undefined,
|
||||
type: sourceName,
|
||||
insert: qualified ? `${q(t.schema)}.${q(t.name)}` : tableText(t),
|
||||
@@ -192,7 +192,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
for (const t of tables) {
|
||||
schemas.add(t.schema);
|
||||
const local = eq(t.schema, defaultSchema) || driver === "sqlite";
|
||||
out.push(tableCandidate(t, local ? (t.kind === "view" ? 28 : 30) : 0, !local));
|
||||
out.push(tableCandidate(t, local ? (t.kind !== "table" ? 28 : 30) : 0, !local));
|
||||
}
|
||||
for (const s of schemas)
|
||||
if (!eq(s, defaultSchema) || schemas.size > 1)
|
||||
@@ -204,7 +204,9 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
case "afterTable": {
|
||||
if (!ctx.ref.alias && ctx.ref.kind === "table" && a.verb !== "INSERT" && a.verb !== "REPLACE") {
|
||||
// IDEA suggests an alias first.
|
||||
const taken = new Set(a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]));
|
||||
const taken = new Set(
|
||||
a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]),
|
||||
);
|
||||
out.push({ label: aliasFor(ctx.ref.name, taken), kind: "alias", boost: 45 });
|
||||
}
|
||||
if (a.verb === "UPDATE") keywords(["SET", "AS"], 20);
|
||||
@@ -237,7 +239,15 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
// The columns an INSERT needs (no default), then all of them.
|
||||
const required = all.filter((c) => !c.hasDefault).map((c) => c.name);
|
||||
if (required.length > 1) out.push(columnsCandidate(ctx.target, required, 60, false));
|
||||
if (all.length > 1 && all.length !== required.length) out.push(columnsCandidate(ctx.target, all.map((c) => c.name), 55, false));
|
||||
if (all.length > 1 && all.length !== required.length)
|
||||
out.push(
|
||||
columnsCandidate(
|
||||
ctx.target,
|
||||
all.map((c) => c.name),
|
||||
55,
|
||||
false,
|
||||
),
|
||||
);
|
||||
out.push(...all.map((c) => columnCandidate(c, ownerOf(ctx.target), t, 50)));
|
||||
keywords(["SELECT", "VALUES"], 5);
|
||||
return out;
|
||||
@@ -271,14 +281,21 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
|
||||
seen.add(key);
|
||||
const ambiguous = r.depth > 0 || (count.get(c.name.toLowerCase()) ?? 0) > 1;
|
||||
out.push(
|
||||
columnCandidate(c, owner, t, r.depth === 0 ? 40 : 20, ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined),
|
||||
columnCandidate(
|
||||
c,
|
||||
owner,
|
||||
t,
|
||||
r.depth === 0 ? 40 : 20,
|
||||
ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (assigning) continue;
|
||||
out.push({ label: refName(r), kind: "alias", boost: 35 });
|
||||
// The whole column list (IDEA's "expand *" item in the select list).
|
||||
const names = columnsOf(r).map((c) => c.name);
|
||||
if (clause === "select" && r.depth === 0 && names.length > 1) out.push(columnsCandidate(r, names, 30, inner.length > 1));
|
||||
if (clause === "select" && r.depth === 0 && names.length > 1)
|
||||
out.push(columnsCandidate(r, names, 30, inner.length > 1));
|
||||
}
|
||||
if (assigning) return;
|
||||
if (!inner.length && a.fromInsert !== null && clause === "select") {
|
||||
@@ -478,5 +495,5 @@ function tableDoc(t: DbCompletionTable): string {
|
||||
const cols = t.columns.map(
|
||||
(c) => ` ${c.name.padEnd(width)} ${c.dataType}${c.nullable ? "" : " NOT NULL"}${c.primaryKey ? " PK" : ""}`,
|
||||
);
|
||||
return `${t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
|
||||
return `${t.kind === "materializedView" ? "MATERIALIZED VIEW" : t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ function definition(path: string): ContributedDefinition {
|
||||
const sym = r?.symbol;
|
||||
const id = consoleSource(path);
|
||||
const reveal = (t: DbCompletionTable, column?: string) => {
|
||||
if (id) void revealInTree(id, t.schema, t.name, t.kind === "view", column);
|
||||
if (id) void revealInTree(id, t.schema, t.name, t.kind, column);
|
||||
};
|
||||
if (!sym) {
|
||||
showHint(view, "Cannot find declaration to go to");
|
||||
@@ -126,13 +126,22 @@ function tableDoc(t: DbCompletionTable, model: DbCompletion | null): string {
|
||||
};
|
||||
const rows = t.columns.map(
|
||||
(c) =>
|
||||
`| ${code(c.name)} | ${c.dataType} | ${[c.primaryKey ? "PK" : "", c.nullable ? "" : "not null", c.hasDefault ? "default" : "", refOf(c.name)]
|
||||
`| ${code(c.name)} | ${c.dataType} | ${[
|
||||
c.primaryKey ? "PK" : "",
|
||||
c.nullable ? "" : "not null",
|
||||
c.hasDefault ? "default" : "",
|
||||
refOf(c.name),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")} |`,
|
||||
);
|
||||
return [`${t.kind === "view" ? "view" : "table"} **${t.name}** in ${code(t.schema)}`, "", "| column | type | |", "|---|---|---|", ...rows].join(
|
||||
"\n",
|
||||
);
|
||||
return [
|
||||
`${t.kind === "materializedView" ? "materialized view" : t.kind} **${t.name}** in ${code(t.schema)}`,
|
||||
"",
|
||||
"| column | type | |",
|
||||
"|---|---|---|",
|
||||
...rows,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function symbolDoc(sym: SqlSymbol, model: DbCompletion | null): string | null {
|
||||
@@ -149,7 +158,11 @@ function symbolDoc(sym: SqlSymbol, model: DbCompletion | null): string | null {
|
||||
const c = sym.column;
|
||||
const owner = sym.table ? `${sym.table.schema}.${sym.table.name}` : sym.owner;
|
||||
if (!c) return `column ${code(sym.name)} of ${code(owner)}`;
|
||||
const traits = [c.primaryKey ? "primary key" : "", c.nullable ? "" : "not null", c.hasDefault ? "has default" : ""].filter(Boolean);
|
||||
const traits = [
|
||||
c.primaryKey ? "primary key" : "",
|
||||
c.nullable ? "" : "not null",
|
||||
c.hasDefault ? "has default" : "",
|
||||
].filter(Boolean);
|
||||
return `column **${c.name}** ${code(c.dataType)}${traits.length ? ` ${traits.join(", ")}` : ""}\n\nof ${code(owner)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,28 @@ import {
|
||||
type DbCompletion,
|
||||
type DbDatabases,
|
||||
type DbDriver,
|
||||
type DbObjectKind,
|
||||
type DbPlanNode,
|
||||
type DbSchemaObjects,
|
||||
type DbStatementOutcome,
|
||||
type DbTableDetails,
|
||||
type DbTableInfo,
|
||||
type DbTableKind,
|
||||
type RequestId,
|
||||
type SqlStatement,
|
||||
} from "@/platform/ipc";
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { editorService, notifications, showServiceView, toolWindowService, type FileDecoration } from "@/workbench/contributions";
|
||||
import {
|
||||
editorService,
|
||||
notifications,
|
||||
showServiceView,
|
||||
toolWindowService,
|
||||
type FileDecoration,
|
||||
} from "@/workbench/contributions";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
|
||||
import type { DbNode } from "./nodes";
|
||||
import { tableFolder, type DbNode } from "./nodes";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import type { DbObjectRef } from "./objects";
|
||||
import { formatted } from "./sql/format";
|
||||
import { bindParams, findParams } from "./sql/params";
|
||||
@@ -41,6 +52,8 @@ export interface ResultTab {
|
||||
path: string;
|
||||
sourceId: string;
|
||||
outcome: DbStatementOutcome;
|
||||
/** The Query Plan tab: the plan of `outcome.statement` (which was explained, not run). */
|
||||
plan?: DbPlanNode[];
|
||||
/** Being re-run. */
|
||||
busy?: boolean;
|
||||
}
|
||||
@@ -80,7 +93,13 @@ export type DialogState =
|
||||
/** SSH password or key passphrase (`prompt` is the backend's reason). */
|
||||
| { kind: "sshSecret"; source: DataSource; prompt: string; resolve: (ok: boolean) => void }
|
||||
/** Values of query parameters (`null`: cancelled). */
|
||||
| { kind: "params"; names: string[]; resolve: (values: Record<string, string> | null) => void };
|
||||
| { kind: "params"; names: string[]; resolve: (values: Record<string, string> | null) => void }
|
||||
/** Export Data of a result or table page; `source` names it (`main.customers`). */
|
||||
| { kind: "export"; data: ExportData; source: string; format?: ExportFormat }
|
||||
/** Browse Query History of target `id`; Paste goes to console `path`. */
|
||||
| { kind: "history"; id: string; path: string | null }
|
||||
/** Import Data from File into a table. */
|
||||
| { kind: "import"; id: string; schema: string; table: string };
|
||||
|
||||
interface DatabaseState {
|
||||
sources: DataSource[];
|
||||
@@ -95,6 +114,8 @@ interface DatabaseState {
|
||||
tables: Record<string, Loaded<DbTableInfo[]>>;
|
||||
/** `${id}\u0000${schema}\u0000${table}` → columns / indexes. */
|
||||
details: Record<string, Loaded<DbTableDetails>>;
|
||||
/** `${id}\u0000${schema}` → routines and sequences (loaded with the tables). */
|
||||
objects: Record<string, DbSchemaObjects>;
|
||||
/** Console / SQL file path → data source id. */
|
||||
consoles: Record<string, string>;
|
||||
completion: Record<string, DbCompletion>;
|
||||
@@ -141,6 +162,7 @@ export const useDatabase = create<DatabaseState>(() => ({
|
||||
schemas: {},
|
||||
tables: {},
|
||||
details: {},
|
||||
objects: {},
|
||||
consoles: readBindings(),
|
||||
completion: {},
|
||||
results: [],
|
||||
@@ -210,6 +232,7 @@ function forget(id: string) {
|
||||
completion: dropKeys(s.completion),
|
||||
tables: dropKeys(s.tables),
|
||||
details: dropKeys(s.details),
|
||||
objects: dropKeys(s.objects),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -399,7 +422,7 @@ export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Ma
|
||||
|
||||
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
|
||||
export function listsDatabases(source: DataSource | undefined): boolean {
|
||||
return source?.driver === "postgres";
|
||||
return source?.driver === "postgres" && !source.options?.singleDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,7 +436,8 @@ export async function refresh(id: string): Promise<void> {
|
||||
return t === id || (whole && splitTarget(t).id === id);
|
||||
};
|
||||
const st = useDatabase.getState();
|
||||
const loaded = (m: Record<string, Loaded<unknown>>) => Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
|
||||
const loaded = (m: Record<string, Loaded<unknown>>) =>
|
||||
Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
|
||||
const schemas = loaded(st.schemas);
|
||||
const tables = loaded(st.tables).map((k) => k.split("\u0000") as [string, string]);
|
||||
const details = loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]);
|
||||
@@ -454,7 +478,12 @@ export async function loadTables(id: string, schema: string): Promise<void> {
|
||||
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
|
||||
try {
|
||||
const value = await withConnection(id, () => call(commands.dbTables(id, schema)));
|
||||
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "ok", value } } }));
|
||||
// Routines / sequences are extras: a server refusing their catalog still lists tables.
|
||||
const objects = await call(commands.dbSchemaObjects(id, schema)).catch(() => null);
|
||||
useDatabase.setState((s) => ({
|
||||
tables: { ...s.tables, [key]: { status: "ok", value } },
|
||||
objects: objects ? { ...s.objects, [key]: objects } : s.objects,
|
||||
}));
|
||||
} catch (e) {
|
||||
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "error", message: message(e) } } }));
|
||||
}
|
||||
@@ -475,7 +504,13 @@ export async function loadDetails(id: string, schema: string, table: string): Pr
|
||||
* Selects a table (or one of its columns) in the Database tool window,
|
||||
* loading and expanding the nodes that lead to it.
|
||||
*/
|
||||
export async function revealInTree(id: string, schema: string, table: string, view: boolean, column?: string): Promise<void> {
|
||||
export async function revealInTree(
|
||||
id: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
kind: DbTableKind,
|
||||
column?: string,
|
||||
): Promise<void> {
|
||||
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
|
||||
const { id: sourceId, database } = splitTarget(id);
|
||||
let databaseNode: string[] = [];
|
||||
@@ -493,10 +528,17 @@ export async function revealInTree(id: string, schema: string, table: string, vi
|
||||
await loadSchemas(id);
|
||||
}
|
||||
if (!loaded(useDatabase.getState().tables[tablesKey(id, schema)])) await loadTables(id, schema);
|
||||
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)])) await loadDetails(id, schema, table);
|
||||
const folder = `${id}/${schema}/${view ? "views" : "tables"}`;
|
||||
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)]))
|
||||
await loadDetails(id, schema, table);
|
||||
const folder = `${id}/${schema}/${tableFolder(kind)}`;
|
||||
const tableKey = `${folder}/${table}`;
|
||||
const open = [sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])];
|
||||
const open = [
|
||||
sourceId,
|
||||
...databaseNode,
|
||||
`${id}/${schema}`,
|
||||
folder,
|
||||
...(column ? [tableKey, `${tableKey}/columns`] : []),
|
||||
];
|
||||
useDatabase.setState((s) => ({
|
||||
treeExpanded: new Set([...s.treeExpanded, ...open]),
|
||||
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
|
||||
@@ -582,7 +624,10 @@ export function outcomeSummary(o: DbStatementOutcome): string {
|
||||
const n = r.columns.length ? r.rows.length : (r.affected ?? 0);
|
||||
const rows = `${n} row${n === 1 ? "" : "s"}`;
|
||||
if (!r.columns.length) return `${rows} affected in ${elapsed(r.elapsedMs ?? 0)}`;
|
||||
return `${rows} retrieved starting from 1 in ${elapsed(r.elapsedMs ?? 0)}${r.hasMore ? " (limit reached)" : ""}`;
|
||||
const total = r.elapsedMs ?? 0;
|
||||
const execution = Math.min(total, r.executionMs ?? total);
|
||||
const split = ` (execution: ${elapsed(execution)}, fetching: ${elapsed(total - execution)})`;
|
||||
return `${rows} retrieved starting from 1 in ${elapsed(total)}${split}${r.hasMore ? " (limit reached)" : ""}`;
|
||||
}
|
||||
|
||||
/** The schema shown as the Output prompt (`main>`). */
|
||||
@@ -593,7 +638,9 @@ function promptOf(id: string): string {
|
||||
/** Runs `statements` on `id`, logging to Output; errors stop the batch (backend). */
|
||||
async function run(id: string, statements: SqlStatement[], request: RequestId): Promise<DbStatementOutcome[]> {
|
||||
const wasConnected = useDatabase.getState().connected.has(id);
|
||||
const outcomes = await withConnection(id, () => call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)));
|
||||
const outcomes = await withConnection(id, () =>
|
||||
call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)),
|
||||
);
|
||||
if (!wasConnected) log("info", "Connected");
|
||||
for (const o of outcomes) {
|
||||
log("statement", o.statement.text, `${promptOf(id)}>`);
|
||||
@@ -621,12 +668,17 @@ export async function runSql(id: string, texts: string[]): Promise<boolean> {
|
||||
const outcomes = await run(id, statements, nextRequestId());
|
||||
const failed = outcomes.find((o) => o.error);
|
||||
if (failed) {
|
||||
notifications().notify({ level: "error", title: `[${targetLabel(id)}] ${failed.statement.text}`, body: failed.error ?? "" });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: `[${targetLabel(id)}] ${failed.statement.text}`,
|
||||
body: failed.error ?? "",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!isCancelled(e)) notifications().notify({ level: "error", title: `Cannot execute on ${targetLabel(id)}`, body: message(e) });
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({ level: "error", title: `Cannot execute on ${targetLabel(id)}`, body: message(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -706,7 +758,12 @@ async function withParams(statements: SqlStatement[], driver: DbDriver): Promise
|
||||
return statements.map((st, i) => ({ ...st, text: bindParams(st.text, found[i]!, values) }));
|
||||
}
|
||||
|
||||
export async function executeConsole(path: string, text: string, from: number, to: number): Promise<DbStatementOutcome[]> {
|
||||
export async function executeConsole(
|
||||
path: string,
|
||||
text: string,
|
||||
from: number,
|
||||
to: number,
|
||||
): Promise<DbStatementOutcome[]> {
|
||||
const id = consoleSource(path);
|
||||
if (!id) {
|
||||
notifications().notify({
|
||||
@@ -719,9 +776,7 @@ export async function executeConsole(path: string, text: string, from: number, t
|
||||
});
|
||||
return [];
|
||||
}
|
||||
let statements: SqlStatement[] = await call(commands.dbStatements(id, text, from, to));
|
||||
if (!statements.length) return [];
|
||||
statements = await withParams(statements, sourceById(id)?.driver ?? "sqlite");
|
||||
const statements = await consoleStatements(id, text, from, to);
|
||||
if (!statements.length) return [];
|
||||
const prev = useDatabase.getState().running[path];
|
||||
if (prev) void commands.cancelRequest(prev);
|
||||
@@ -765,13 +820,102 @@ export async function executeConsole(path: string, text: string, from: number, t
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-runs the statement of result tab `tabId`, replacing its rows. */
|
||||
/** The statements of console `path` to run or explain: the selection's, else the one at `from`, parameters bound. */
|
||||
async function consoleStatements(id: string, text: string, from: number, to: number): Promise<SqlStatement[]> {
|
||||
const statements: SqlStatement[] = await call(commands.dbStatements(id, text, from, to));
|
||||
if (!statements.length) return [];
|
||||
return withParams(statements, sourceById(id)?.driver ?? "sqlite");
|
||||
}
|
||||
|
||||
const PLAN_TITLE = "Query Plan";
|
||||
|
||||
/** Shows `plan` of `statement` in the window's Query Plan tab (one per window, like IDEA's). */
|
||||
function showPlan(path: string, id: string, statement: SqlStatement, plan: DbPlanNode[]) {
|
||||
useDatabase.setState((s) => {
|
||||
const old = s.results.find((r) => r.plan);
|
||||
const tab: ResultTab = {
|
||||
id: old?.id ?? nextTab++,
|
||||
title: PLAN_TITLE,
|
||||
path,
|
||||
sourceId: id,
|
||||
outcome: { statement, result: null, error: null },
|
||||
plan,
|
||||
};
|
||||
const results = old ? s.results.map((r) => (r === old ? tab : r)) : [...s.results, tab].slice(-MAX_TABS);
|
||||
return { results, activeResult: tab.id };
|
||||
});
|
||||
showServiceView(RESULTS_VIEW, false);
|
||||
}
|
||||
|
||||
async function explain(id: string, statement: SqlStatement): Promise<DbPlanNode[]> {
|
||||
const plan = await withConnection(id, () => call(commands.dbExplain(nextRequestId(), id, statement.text)));
|
||||
log("statement", `explain ${statement.text}`, `${promptOf(id)}>`);
|
||||
log("result", `${plan.length} plan node${plan.length === 1 ? "" : "s"}`);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Explain Plan: the plan of the statement at `from` (the first selected one) of console `path`. */
|
||||
export async function explainConsole(path: string, text: string, from: number, to: number): Promise<void> {
|
||||
const id = consoleSource(path);
|
||||
if (!id) return;
|
||||
try {
|
||||
const [statement] = await consoleStatements(id, text, from, to);
|
||||
if (!statement) return;
|
||||
showPlan(path, id, statement, await explain(id, statement));
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return;
|
||||
log("error", `[${targetLabel(id)}] ${message(e)}`);
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot explain the statement",
|
||||
body: message(e),
|
||||
key: "db-explain",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Browse Query History of the data source of console `path` (or of target `id`). */
|
||||
export function openHistory(path: string | null, id: string | null = path ? consoleSource(path) : null) {
|
||||
if (!id) {
|
||||
notifications().notify({ level: "info", title: "Choose a data source for this console", key: "db-history" });
|
||||
return;
|
||||
}
|
||||
openDialog({ kind: "history", id, path });
|
||||
}
|
||||
|
||||
/** Go to DDL of a routine, trigger or sequence: its definition in a SQL file next to the console. */
|
||||
export async function openObjectDdl(id: string, schema: string, kind: DbObjectKind, name: string): Promise<void> {
|
||||
try {
|
||||
const [text, console] = await Promise.all([
|
||||
withConnection(id, () => call(commands.dbObjectDdl(id, schema, kind, name))),
|
||||
call(commands.dbConsolePath(id)),
|
||||
]);
|
||||
const safe = (s: string) => s.replace(/[^\w.-]+/g, "_");
|
||||
const path = console.replace(/[^\\/]*$/, `${safe(schema)}.${safe(name)}.sql`);
|
||||
await call(commands.dbExportFile(path, text));
|
||||
bindConsole(path, id);
|
||||
await editorService().openFile(path, { focus: true });
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: `Cannot get the definition of ${name}`, body: message(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-runs the statement of result tab `tabId`, replacing its rows (a plan tab explains it again). */
|
||||
export async function rerunResult(tabId: number): Promise<void> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy) return;
|
||||
const patch = (p: Partial<ResultTab>) =>
|
||||
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
|
||||
patch({ busy: true });
|
||||
if (tab.plan) {
|
||||
try {
|
||||
patch({ plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
|
||||
} catch (e) {
|
||||
patch({ busy: false });
|
||||
if (!isCancelled(e)) log("error", `[${targetLabel(tab.sourceId)}] ${message(e)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId());
|
||||
patch(outcome?.result ? { outcome, busy: false } : { busy: false });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { call, commands, type DataSource, type DbDriver, type DbTableInfo } from
|
||||
import { registerActions, type Action } from "@/platform/actions";
|
||||
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
|
||||
import { splitTarget } from "./model";
|
||||
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
|
||||
import { nodeTarget, objectOf, schemaObjectOf, tableFolder, tableOf, type DbNode } from "./nodes";
|
||||
import { canDrop, canRename, reference, scriptSql, type ScriptKind } from "./objects";
|
||||
import {
|
||||
DB_TOOL_WINDOW,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
openConsole,
|
||||
openConsoleWith,
|
||||
openDdl,
|
||||
openObjectDdl,
|
||||
openDialog,
|
||||
openSourceDialog,
|
||||
readableDdl,
|
||||
@@ -66,7 +67,9 @@ async function copy(text: string, what: string) {
|
||||
|
||||
/** Where Quick Documentation opens: below the tree's selected row. */
|
||||
function rowAnchor(): { x: number; y: number } {
|
||||
const row = document.querySelector('[role="tree"][aria-label="Data sources"] [role="treeitem"][aria-selected="true"]');
|
||||
const row = document.querySelector(
|
||||
'[role="tree"][aria-label="Data sources"] [role="treeitem"][aria-selected="true"]',
|
||||
);
|
||||
const r = row?.getBoundingClientRect();
|
||||
return r ? { x: r.left + 24, y: r.bottom + 2 } : { x: window.innerWidth / 2, y: window.innerHeight / 3 };
|
||||
}
|
||||
@@ -89,6 +92,9 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
|
||||
const one = async (schema: string, table: string) =>
|
||||
readableDdl(await withConnection(id, () => call(commands.dbDdl(id, schema, table))), driverOf(id));
|
||||
try {
|
||||
const o = schemaObjectOf(n);
|
||||
if (o)
|
||||
return { id, text: await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name))) };
|
||||
const t = tableOf(n);
|
||||
if (t) return { id, text: await one(t.schema, t.table) };
|
||||
let schemas: string[] = [];
|
||||
@@ -100,14 +106,20 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const schema of schemas) {
|
||||
const tables = (await tablesIn(id, schema)).filter((t) => n.kind !== "folder" || (t.kind === "view") === (n.folder === "views"));
|
||||
const tables = (await tablesIn(id, schema)).filter(
|
||||
(t) => n.kind !== "folder" || tableFolder(t.kind) === n.folder,
|
||||
);
|
||||
// Tables before views: views select from them.
|
||||
tables.sort((a, b) => Number(a.kind === "view") - Number(b.kind === "view"));
|
||||
tables.sort((a, b) => Number(a.kind !== "table") - Number(b.kind !== "table"));
|
||||
for (const t of tables) parts.push(await one(schema, t.name));
|
||||
}
|
||||
return { id, text: parts.join("\n") };
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot generate the DDL", body: e instanceof Error ? e.message : String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot generate the DDL",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -126,17 +138,34 @@ export async function insertScript(n: DbNode, kind: ScriptKind) {
|
||||
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
|
||||
async function runScriptFile(id: string) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const file = await open({ multiple: false, directory: false, title: "Run SQL Script", filters: [{ name: "SQL", extensions: ["sql", "ddl"] }] });
|
||||
const file = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
title: "Run SQL Script",
|
||||
filters: [{ name: "SQL", extensions: ["sql", "ddl"] }],
|
||||
});
|
||||
if (typeof file !== "string") return;
|
||||
try {
|
||||
const text = await call(commands.fsReadText(file));
|
||||
const statements = await call(commands.dbStatements(id, text, 0, text.length));
|
||||
useDatabase.setState({ activeResult: OUTPUT_TAB });
|
||||
showServiceView(RESULTS_VIEW, false);
|
||||
if (await runSql(id, statements.map((s) => s.text)))
|
||||
notifications().notify({ level: "info", title: `Script finished: ${statements.length} statement${statements.length === 1 ? "" : "s"}` });
|
||||
if (
|
||||
await runSql(
|
||||
id,
|
||||
statements.map((s) => s.text),
|
||||
)
|
||||
)
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: `Script finished: ${statements.length} statement${statements.length === 1 ? "" : "s"}`,
|
||||
});
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot run the script", body: e instanceof Error ? e.message : String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot run the script",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +185,12 @@ function refreshNode(n: DbNode | null) {
|
||||
}
|
||||
|
||||
export function registerDatabaseTreeActions(): () => void {
|
||||
const tree = (a: Omit<Action, "category">): Action => ({ category: "Database", when: OPEN, keyWhen: DB_VIEW_FOCUS, ...a });
|
||||
const tree = (a: Omit<Action, "category">): Action => ({
|
||||
category: "Database",
|
||||
when: OPEN,
|
||||
keyWhen: DB_VIEW_FOCUS,
|
||||
...a,
|
||||
});
|
||||
return registerActions([
|
||||
tree({
|
||||
id: "db.properties",
|
||||
@@ -223,6 +257,8 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
keywords: ["edit source", "definition", "create table"],
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
const o = n && schemaObjectOf(n);
|
||||
if (o) return void openObjectDdl(o.id, o.schema, o.kind, o.name);
|
||||
const t = n && tableOf(n);
|
||||
if (t) void openDdl(t.id, t.schema, t.table);
|
||||
},
|
||||
@@ -269,7 +305,8 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
keywords: ["delete all rows", "empty table"],
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
if (n?.kind === "table" && n.table.kind !== "view") openDialog({ kind: "truncate", id: n.id, schema: n.schema, table: n.table.name });
|
||||
if (n?.kind === "table" && n.table.kind === "table")
|
||||
openDialog({ kind: "truncate", id: n.id, schema: n.schema, table: n.table.name });
|
||||
},
|
||||
}),
|
||||
tree({
|
||||
@@ -281,7 +318,13 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
const n = nodeFrom(arg);
|
||||
const t = n && tableOf(n);
|
||||
if (!t || t.view) return;
|
||||
openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, focusColumn: n.kind === "column" ? n.column.name : undefined });
|
||||
openDialog({
|
||||
kind: "modifyTable",
|
||||
id: t.id,
|
||||
schema: t.schema,
|
||||
table: t.table,
|
||||
focusColumn: n.kind === "column" ? n.column.name : undefined,
|
||||
});
|
||||
},
|
||||
}),
|
||||
tree({
|
||||
@@ -293,7 +336,8 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
const n = nodeFrom(arg);
|
||||
const id = n && nodeTarget(n);
|
||||
if (!n || !id) return;
|
||||
const schema = "schema" in n && typeof n.schema === "string" ? n.schema : driverOf(id) === "sqlite" ? "main" : null;
|
||||
const schema =
|
||||
"schema" in n && typeof n.schema === "string" ? n.schema : driverOf(id) === "sqlite" ? "main" : null;
|
||||
if (schema !== null) openDialog({ kind: "modifyTable", id, schema, table: null });
|
||||
},
|
||||
}),
|
||||
@@ -305,7 +349,19 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
const t = n && tableOf(n);
|
||||
if (t && !t.view) openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, addColumn: true });
|
||||
if (t && !t.view)
|
||||
openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, addColumn: true });
|
||||
},
|
||||
}),
|
||||
tree({
|
||||
id: "db.importData",
|
||||
title: "Import Data from File…",
|
||||
icon: "import",
|
||||
keywords: ["csv", "tsv", "load data"],
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
if (n?.kind === "table" && n.table.kind === "table")
|
||||
openDialog({ kind: "import", id: n.id, schema: n.schema, table: n.table.name });
|
||||
},
|
||||
}),
|
||||
tree({
|
||||
@@ -383,7 +439,7 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
keywords: ["foreign key"],
|
||||
run: ({ arg }) => {
|
||||
const n = nodeFrom(arg);
|
||||
if (n?.kind === "foreignKey") void revealInTree(n.id, n.fk.refSchema, n.fk.refTable, false);
|
||||
if (n?.kind === "foreignKey") void revealInTree(n.id, n.fk.refSchema, n.fk.refTable, "table");
|
||||
},
|
||||
}),
|
||||
tree({
|
||||
|
||||
@@ -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)] },
|
||||
|
||||
Reference in new issue
Заблокувати користувача