diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css index 9a6381303..fa103b667 100644 --- a/src/features/database/Database.module.css +++ b/src/features/database/Database.module.css @@ -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; +} diff --git a/src/features/database/Grid.test.tsx b/src/features/database/Grid.test.tsx new file mode 100644 index 000000000..266001c2b --- /dev/null +++ b/src/features/database/Grid.test.tsx @@ -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( + + [ + ["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( + "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"], + ]); + }); +}); diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index 8029a90ed..3b05b4f67 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -3,11 +3,11 @@ * cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV, `` * 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(null); const [sel, setSel] = useState(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([]); @@ -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) => { + 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} >
@@ -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} > diff --git a/src/features/database/SqlFilterInput.test.tsx b/src/features/database/SqlFilterInput.test.tsx new file mode 100644 index 000000000..6859a056a --- /dev/null +++ b/src/features/database/SqlFilterInput.test.tsx @@ -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 ( + + ); + } + render(); + 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 ( + + ); + } + it.each([ + ["'", "''"], + ['"', '""'], + ["(", "()"], + ])("pairs %s without opening completion", (key, paired) => { + render(); + 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(); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: character, selectionStart: 1 } }); + expect(screen.queryByRole("listbox")).toBeNull(); + }); +}); diff --git a/src/features/database/SqlFilterInput.tsx b/src/features/database/SqlFilterInput.tsx new file mode 100644 index 000000000..bdfa33f64 --- /dev/null +++ b/src/features/database/SqlFilterInput.tsx @@ -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) => void; + onBlur?: () => void; + kind: "where" | "order"; + schema: string; + table: string; + columns: readonly { name: string; type: string }[]; + driver: DbDriver; + invalid?: boolean; +}) { + const ref = useRef(null); + const highlightRef = useRef(null); + const pendingCaret = useRef(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( + () => ({ + 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 ( + <> +
+ + { + 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 = { "(": ")", "[": "]", "{": "}", "'": "'", '"': '"' }; + 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); + }} + /> +
+ {open && options.length > 0 && ( + setOpen(false)} + autoFocus={false} + restoreFocus={false} + role="listbox" + aria-label="SQL completion" + className={styles.sqlCompletion} + > + {options.map((c, i) => ( + + ))} + + )} + + ); +} diff --git a/src/features/database/SqlHighlight.test.tsx b/src/features/database/SqlHighlight.test.tsx new file mode 100644 index 000000000..0e6ee205e --- /dev/null +++ b/src/features/database/SqlHighlight.test.tsx @@ -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(); + 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(); + 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"); + }); +}); diff --git a/src/features/database/SqlHighlight.tsx b/src/features/database/SqlHighlight.tsx new file mode 100644 index 000000000..25d213965 --- /dev/null +++ b/src/features/database/SqlHighlight.tsx @@ -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(() => 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 [{text}]; + const state = EditorState.create({ doc: text, extensions: [support] }); + const tree = ensureSyntaxTree(state, text.length, 100); + if (!tree) return [{text}]; + 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({text.slice(offset, from)}); + result.push( + + {text.slice(from, to)} + , + ); + offset = to; + }); + if (offset < text.length) result.push({text.slice(offset)}); + return result; + }, [support, text]); + return ( + <> + + {pieces} + + ); +} diff --git a/src/features/database/TableEditor.tsx b/src/features/database/TableEditor.tsx index 7e9acdf0c..65adc30e0 100644 --- a/src/features/database/TableEditor.tsx +++ b/src/features/database/TableEditor.tsx @@ -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 }) {