дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
06d78cf524
коміт
75b1704f78
11 files changed
+522
-19
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 脚本",
|
||||
|
||||
Reference in new issue
Заблокувати користувача