feat(database): add grid range editing and SQL filters

Add rectangular database cell selection and TSV copy/paste, plus schema-aware SQL completion and highlighting in WHERE and ORDER BY fields. Preserve whitespace in highlighted SQL and match the input caret scroll width so the overlay remains aligned.

Co-authored-by: zzxzz12345 <[email protected]>
Цей коміт міститься в:
胡翀таzzxzz12345 authored and GitHub committed 2026-10-29 14:21:14 +08:00
1 джерело 06d78cf524
коміт 75b1704f78
11 files changed
+522 -19

No files matched your search

+57
Переглянути файл
@@ -1202,3 +1202,60 @@
color: var(--fg-strong);
font-weight: 600;
}
.sqlCompletion {
width: 300px;
max-height: 240px;
padding: 4px;
}
.sqlCompletionOption {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
padding: 4px 8px;
text-align: left;
}
.sqlCompletionOption[aria-selected="true"],
.sqlCompletionOption:hover {
background: var(--bg-toolbar-selected);
}
.sqlCompletionOption span {
flex: 1;
}
.sqlCompletionOption small {
color: var(--fg-muted);
}
.sqlInputWrap {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
}
.sqlInputWrap > input {
width: 100%;
position: relative;
}
.sqlInputText {
color: transparent !important;
caret-color: var(--fg-strong);
}
.sqlHighlight {
position: absolute;
inset: 0;
overflow: hidden;
white-space: pre;
pointer-events: none;
display: flex;
align-items: center;
font-family: var(--font-mono);
font-size: var(--fs-ui);
color: var(--fg-strong);
}
/* The input's scroll width includes room for the caret; match it so both stop at the same scrollLeft. */
.sqlHighlight::after {
content: "";
flex: none;
width: 2px;
}
+61
Переглянути файл
@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Grid } from "./Grid";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 24,
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 24, key: index })),
scrollToIndex: () => {},
}),
}));
describe("database grid", () => {
it("drags a rectangle and copies it as TSV", () => {
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
render(
<Grid
columns={[
{ name: "a", type: "text" },
{ name: "b", type: "text" },
]}
rowCount={2}
value={(r, c) =>
[
["one", "two"],
["three", "four"],
][r]![c]!
}
/>,
);
const cells = screen.getAllByRole("gridcell");
fireEvent.mouseDown(cells[0]!, { button: 0 });
fireEvent.mouseEnter(cells[3]!);
fireEvent.mouseUp(window);
fireEvent.keyDown(screen.getByRole("grid"), { key: "c", metaKey: true });
expect(writeText).toHaveBeenCalledWith("one\ttwo\nthree\tfour");
});
it("pastes a rectangle as pending edits and skips deleted rows", () => {
const onEdit = vi.fn();
render(
<Grid
columns={[
{ name: "a", type: "text" },
{ name: "b", type: "text" },
]}
rowCount={2}
value={() => "old"}
editable
onEdit={onEdit}
rowState={(r) => (r === 1 ? "deleted" : null)}
/>,
);
fireEvent.mouseDown(screen.getAllByRole("gridcell")[0]!, { button: 0 });
fireEvent.paste(screen.getByRole("grid"), { clipboardData: { getData: () => "new\tother\nignored\tignored" } });
expect(onEdit.mock.calls).toEqual([
[0, 0, "new"],
[0, 1, "other"],
]);
});
});
+39 -2
Переглянути файл
@@ -3,11 +3,11 @@
* cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV, `<null>`
* rendering, sortable headers and in-place editing (table editor).
*/
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import clsx from "clsx";
import { Icon, showContextMenu, type MenuItem } from "@/ui";
import { toCsv, toTsv, type Cell } from "./model";
import { parseDelimited, toCsv, toTsv, type Cell } from "./model";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -95,6 +95,14 @@ export function Grid(props: GridProps) {
const { columns, rowCount, value, editable, onEdit, sort, onSort, rowMenu, firstRowNumber = 1 } = props;
const scrollRef = useRef<HTMLDivElement>(null);
const [sel, setSel] = useState<Sel | null>(null);
const dragging = useRef(false);
useEffect(() => {
const stop = () => {
dragging.current = false;
};
window.addEventListener("mouseup", stop);
return () => window.removeEventListener("mouseup", stop);
}, []);
const [editing, setEditing] = useState<{ row: number; col: number; text: string } | null>(null);
const columnKey = columns.map((c) => c.name).join("\u0000");
const [widths, setWidths] = useState<number[]>([]);
@@ -206,9 +214,34 @@ export function Grid(props: GridProps) {
return;
}
if (editing) commit();
e.preventDefault();
scrollRef.current?.focus();
dragging.current = true;
setSel(e.shiftKey && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
};
const onPaste = (e: ClipboardEvent<HTMLDivElement>) => {
if (editing || !editable || !onEdit || !sel) return;
const text = e.clipboardData.getData("text/plain");
if (!text) return;
e.preventDefault();
const rows = parseDelimited(text, "\t");
const { top, left } = norm(sel);
let bottom = top;
let right = left;
rows.forEach((cells, dr) =>
cells.forEach((cell, dc) => {
const row = top + dr,
col = left + dc;
if (row >= rowCount || col >= columns.length || props.rowState?.(row) === "deleted") return;
onEdit(row, col, cell);
bottom = Math.max(bottom, row);
right = Math.max(right, col);
}),
);
setSel({ r0: top, c0: left, r1: bottom, c1: right });
};
const onRowNumberMouseDown = (e: MouseEvent, r: number) => {
if (e.button !== 0 && sel && selected(r, 0)) return;
const last = columns.length - 1;
@@ -266,6 +299,7 @@ export function Grid(props: GridProps) {
aria-label={props["aria-label"] ?? "Results"}
aria-rowcount={rowCount}
onKeyDown={onKeyDown}
onPaste={onPaste}
onContextMenu={onContextMenu}
>
<div className={styles.gridInner} style={{ width: totalWidth, height: virtualizer.getTotalSize() + ROW_H }}>
@@ -367,6 +401,9 @@ export function Grid(props: GridProps) {
props.isModified?.(r, c) && styles.cellModified,
)}
onMouseDown={(e) => onCellMouseDown(e, r, c)}
onMouseEnter={() => {
if (dragging.current) setSel((s) => (s ? { ...s, r1: r, c1: c } : s));
}}
onDoubleClick={() => startEdit(r, c)}
title={val != null && val.length > 40 ? val.slice(0, 2000) : undefined}
>
+70
Переглянути файл
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { SqlFilterInput } from "./SqlFilterInput";
describe("table SQL completion", () => {
it.each(["where", "order"] as const)("suggests the current table columns in %s", (kind) => {
function Example() {
const [value, setValue] = useState("");
return (
<SqlFilterInput
value={value}
onChange={setValue}
onKeyDown={vi.fn()}
kind={kind}
schema="main"
table="users"
columns={[
{ name: "email", type: "text" },
{ name: "id", type: "integer" },
]}
driver="sqlite"
/>
);
}
render(<Example />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "ema", selectionStart: 3 } });
expect(screen.getByRole("option", { name: /email/i })).toBeTruthy();
expect(screen.queryByRole("option", { name: /^id /i })).toBeNull();
fireEvent.keyDown(input, { key: "Enter" });
expect((input as HTMLInputElement).value).toBe("email");
});
});
describe("SQL filter typing", () => {
function Example() {
const [value, setValue] = useState("");
return (
<SqlFilterInput
value={value}
onChange={setValue}
onKeyDown={vi.fn()}
kind="where"
schema="main"
table="users"
columns={[{ name: "email", type: "text" }]}
driver="sqlite"
/>
);
}
it.each([
["'", "''"],
['"', '""'],
["(", "()"],
])("pairs %s without opening completion", (key, paired) => {
render(<Example />);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.keyDown(input, { key });
expect(input.value).toBe(paired);
expect(input.selectionStart).toBe(1);
expect(screen.queryByRole("listbox")).toBeNull();
});
it.each([" ", "'", '"', "("])("does not automatically complete after %s", (character) => {
render(<Example />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: character, selectionStart: 1 } });
expect(screen.queryByRole("listbox")).toBeNull();
});
});
+202
Переглянути файл
@@ -0,0 +1,202 @@
import { useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import type { DbCompletion, DbDriver } from "@/platform/ipc";
import { Input, Popup } from "@/ui";
import { editorPrefs } from "@/platform/editorSettings";
import { analyze } from "./sql/analyze";
import { quoteIdent, shouldAutoCompleteSql, sqlCandidates } from "./sql/complete";
import { inLiteral, lex } from "./sql/lexer";
import styles from "./Database.module.css";
import { SqlHighlight } from "./SqlHighlight";
export function SqlFilterInput({
value,
onChange,
onKeyDown,
onBlur,
kind,
schema,
table,
columns,
driver,
invalid,
}: {
value: string;
onChange: (value: string) => void;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
onBlur?: () => void;
kind: "where" | "order";
schema: string;
table: string;
columns: readonly { name: string; type: string }[];
driver: DbDriver;
invalid?: boolean;
}) {
const ref = useRef<HTMLInputElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const pendingCaret = useRef<number | null>(null);
useLayoutEffect(() => {
if (pendingCaret.current !== null) {
ref.current?.setSelectionRange(pendingCaret.current, pendingCaret.current);
pendingCaret.current = null;
}
}, [value]);
const [caret, setCaret] = useState(0);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const prefix = `SELECT * FROM ${quoteIdent(schema, driver)}.${quoteIdent(table, driver)} ${kind === "where" ? "WHERE" : "ORDER BY"} `;
const model = useMemo<DbCompletion>(
() => ({
defaultSchema: schema,
foreignKeys: [],
tables: [
{
schema,
name: table,
kind: "table",
columns: columns.map((c) => ({
name: c.name,
dataType: c.type,
nullable: true,
primaryKey: false,
hasDefault: false,
})),
},
],
}),
[schema, table, columns],
);
const analysis = analyze(prefix + value, prefix.length + caret, driver);
const from = Math.max(0, analysis.prefix.from - prefix.length);
const typed = value.slice(from, caret).toLowerCase();
const candidates = sqlCandidates({ model, analysis, driver }).filter(
(c) => c.kind === "column" || (kind === "where" && ["keyword", "function"].includes(c.kind)),
);
if (kind === "order")
candidates.push(...["ASC", "DESC"].map((label) => ({ label, kind: "keyword" as const, boost: 0 })));
const options = candidates
.filter((c, i, a) => c.label.toLowerCase().startsWith(typed) && a.findIndex((v) => v.label === c.label) === i)
.slice(0, 30);
const choose = (item: (typeof options)[number]) => {
const replacement = kind === "order" && item.kind === "column" ? item.label : (item.insert ?? item.label);
const next = value.slice(0, from) + replacement + value.slice(caret);
onChange(next);
setOpen(false);
requestAnimationFrame(() => {
ref.current?.focus();
ref.current?.setSelectionRange(from + replacement.length, from + replacement.length);
});
};
return (
<>
<div className={styles.sqlInputWrap}>
<div ref={highlightRef} className={styles.sqlHighlight} aria-hidden="true">
<SqlHighlight text={value} />
</div>
<Input
ref={ref}
className={value ? styles.sqlInputText : undefined}
onScroll={(e) => {
if (highlightRef.current) highlightRef.current.scrollLeft = e.currentTarget.scrollLeft;
}}
variant="bare"
mono
aria-label={kind === "where" ? "WHERE filter" : "ORDER BY"}
placeholder={kind === "where" ? "WHERE" : "ORDER BY"}
invalid={invalid}
value={value}
onChange={(e) => {
onChange(e.target.value);
setCaret(e.target.selectionStart ?? e.target.value.length);
setActive(0);
const pos = e.target.selectionStart ?? e.target.value.length;
setOpen(shouldAutoCompleteSql(analyze(prefix + e.target.value, prefix.length + pos, driver)));
}}
onClick={(e) => setCaret(e.currentTarget.selectionStart ?? value.length)}
onBlur={onBlur}
onKeyDown={(e) => {
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
const start = e.currentTarget.selectionStart ?? value.length;
const end = e.currentTarget.selectionEnd ?? start;
const pairs: Record<string, string> = { "(": ")", "[": "]", "{": "}", "'": "'", '"': '"' };
const closer = pairs[e.key];
const quote = e.key === "'" || e.key === '"';
const prefs = editorPrefs().smartKeys;
const literal = inLiteral(lex(value, driver), start);
if (start === end && value[start] === e.key && (quote || ")]}".includes(e.key))) {
e.preventDefault();
e.currentTarget.setSelectionRange(start + 1, start + 1);
setCaret(start + 1);
setOpen(false);
return;
}
if (
closer &&
start === end &&
(quote ? prefs.pairQuotes : prefs.pairBrackets) &&
!literal &&
(!value[start] || /[\s)\]};,]/.test(value[start]!))
) {
e.preventDefault();
pendingCaret.current = start + 1;
onChange(value.slice(0, start) + e.key + closer + value.slice(end));
setCaret(start + 1);
setOpen(false);
return;
}
}
if (e.ctrlKey && e.key === " ") {
e.preventDefault();
setCaret(e.currentTarget.selectionStart ?? value.length);
setOpen(true);
setActive(0);
return;
}
if (open && options.length && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
setActive((active + (e.key === "ArrowDown" ? 1 : -1) + options.length) % options.length);
return;
}
if (open && options.length && (e.key === "Enter" || e.key === "Tab")) {
e.preventDefault();
choose(options[Math.min(active, options.length - 1)]!);
return;
}
if (open && e.key === "Escape") {
e.preventDefault();
setOpen(false);
return;
}
onKeyDown(e);
}}
/>
</div>
{open && options.length > 0 && (
<Popup
open
anchor={ref}
onClose={() => setOpen(false)}
autoFocus={false}
restoreFocus={false}
role="listbox"
aria-label="SQL completion"
className={styles.sqlCompletion}
>
{options.map((c, i) => (
<button
type="button"
role="option"
aria-selected={i === active}
key={`${c.kind}:${c.label}`}
className={styles.sqlCompletionOption}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(c)}
>
<span>{c.label}</span>
<small>{c.type ?? c.kind}</small>
</button>
))}
</Popup>
)}
</>
);
}
+19
Переглянути файл
@@ -0,0 +1,19 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SqlHighlight } from "./SqlHighlight";
describe("SQL filter highlighting", () => {
it("uses the console SQL language and theme to highlight keywords, strings and numbers", async () => {
const { container } = render(<SqlHighlight text="name = 'active' AND id > 12" />);
await waitFor(() => expect(container.querySelectorAll("span[class]").length).toBeGreaterThanOrEqual(3));
expect(container.textContent).toContain("name = 'active' AND id > 12");
});
it("wraps the gaps between tokens so flex layout keeps single spaces", async () => {
const { container } = render(<SqlHighlight text="age > 20 AND id = 1" />);
await waitFor(() => expect(container.querySelectorAll("span[class]").length).toBeGreaterThanOrEqual(3));
const bare = [...container.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE);
expect(bare).toEqual([]);
const spans = [...container.children].filter((n) => n.tagName === "SPAN");
expect(spans.map((n) => n.textContent).join("")).toBe("age > 20 AND id = 1");
});
});
+49
Переглянути файл
@@ -0,0 +1,49 @@
import { useEffect, useMemo, useState } from "react";
import { EditorState } from "@codemirror/state";
import { ensureSyntaxTree, type LanguageSupport } from "@codemirror/language";
import { highlightTree } from "@lezer/highlight";
import { findLanguageDescription, loadSupport, loadedSupport } from "@/editor/core/cm/languages";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
const sql = findLanguageDescription("SQL", "console.sql");
/** Render with the same language mode and highlight style as a SQL console. */
export function SqlHighlight({ text }: { text: string }) {
const [support, setSupport] = useState<LanguageSupport | null>(() => loadedSupport(sql));
useEffect(() => {
if (!sql || support) return;
let mounted = true;
void loadSupport(sql).then((value) => {
if (mounted) setSupport(value);
});
return () => {
mounted = false;
};
}, [support]);
const pieces = useMemo(() => {
if (!support) return [<span key={0}>{text}</span>];
const state = EditorState.create({ doc: text, extensions: [support] });
const tree = ensureSyntaxTree(state, text.length, 100);
if (!tree) return [<span key={0}>{text}</span>];
const result: React.ReactNode[] = [];
let offset = 0;
highlightTree(tree, openideaHighlightStyle, (from, to, classes) => {
// Every piece is an element: the overlay is a flex row, which drops bare whitespace-only text.
if (from > offset) result.push(<span key={`t${offset}`}>{text.slice(offset, from)}</span>);
result.push(
<span key={from} className={classes}>
{text.slice(from, to)}
</span>,
);
offset = to;
});
if (offset < text.length) result.push(<span key={`t${offset}`}>{text.slice(offset)}</span>);
return result;
}, [support, text]);
return (
<>
<style>{openideaHighlightStyle.module?.getRules()}</style>
{pieces}
</>
);
}
+16 -13
Переглянути файл
@@ -13,8 +13,9 @@ import {
type DbTablePage,
type RequestId,
} from "@/platform/ipc";
import { Button, EmptyState, Icon, IconButton, Input, Select, Spinner, T, showContextMenu } from "@/ui";
import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { notifications, type EditorInput } from "@/workbench/contributions";
import { SqlFilterInput } from "./SqlFilterInput";
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
import type { ExportFormat } from "./export";
import {
@@ -257,13 +258,14 @@ export function TableEditor({ input }: { input: EditorInput }) {
<div className={styles.filterBar}>
<label className={styles.filterField}>
<Icon name="filter" size={14} />
<Input
variant="bare"
mono
aria-label="WHERE filter"
placeholder="WHERE"
<SqlFilterInput
kind="where"
schema={ref.schema}
table={ref.table}
columns={gridColumns}
driver={sourceById(ref.id)?.driver ?? "sqlite"}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onChange={setFilterText}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOffset(0);
@@ -274,14 +276,15 @@ export function TableEditor({ input }: { input: EditorInput }) {
</label>
<label className={styles.filterField}>
<Icon name="db-sort" size={14} />
<Input
variant="bare"
mono
aria-label="ORDER BY"
placeholder="ORDER BY"
<SqlFilterInput
kind="order"
schema={ref.schema}
table={ref.table}
columns={gridColumns}
driver={sourceById(ref.id)?.driver ?? "sqlite"}
invalid={orderInvalid}
value={orderDraft ?? formatOrderBy(sort)}
onChange={(e) => setOrderDraft(e.target.value)}
onChange={setOrderDraft}
onBlur={() => !orderInvalid && setOrderDraft(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setOrderDraft(null);
+2 -4
Переглянути файл
@@ -38,7 +38,7 @@ import { shortcutLabel } from "@/platform/keymap";
import { IconButton, Select } from "@/ui";
import { sourceLabel, splitTarget, targetOf } from "./model";
import { analyze, templateContexts, valuesSlot } from "./sql/analyze";
import { sqlCandidates, type CandidateKind, type SqlCandidate } from "./sql/complete";
import { shouldAutoCompleteSql, sqlCandidates, type CandidateKind, type SqlCandidate } from "./sql/complete";
import { sqlSupport } from "./sqlSupport";
import {
bindConsole,
@@ -427,9 +427,7 @@ function completionSource(path: string): CompletionSource {
const analysis = placeholder
? analyze(doc.slice(0, sel.from) + doc.slice(sel.to), sel.from, driver)
: analyze(doc, ctx.pos, driver);
const typed = analysis.prefix.text;
const afterDot = !!analysis.qualifier && !typed;
if (!ctx.explicit && ((!typed && !afterDot) || /^\d/.test(typed))) return null;
if (!ctx.explicit && !shouldAutoCompleteSql(analysis)) return null;
const candidates = sqlCandidates({ model, analysis, driver, sourceName: source?.name ?? undefined });
if (!candidates.length) return null;
return {
+6
Переглянути файл
@@ -47,6 +47,12 @@ export interface SqlCandidate {
doc?: string;
}
/** Match the console's automatic trigger: a word or a qualifier followed by a dot. */
export function shouldAutoCompleteSql(analysis: Analysis): boolean {
const typed = analysis.prefix.text;
return analysis.context.kind !== "none" && (!!typed || !!analysis.qualifier) && !/^\d/.test(typed);
}
const MAX_ALL_COLUMNS = 3000;
const eq = (a: string | null | undefined, b: string | null | undefined) =>
+1
Переглянути файл
@@ -1731,6 +1731,7 @@
"Search history": "搜索历史",
"Search history (⌥↑ / ⌥↓)": "搜索历史(⌥↑ / ⌥↓)",
"Searching for usages of '{symbol}'": "正在搜索 '{symbol}' 的用法",
"SQL completion": "SQL 补全",
"SQL Inserts": "SQL Insert 语句",
"SQL Script Template": "SQL 脚本模板",
"SQL Scripts": "SQL 脚本",