feat(database): search and pagination on result query (#1432)

Adds a search bar to query result grids (match case / words / regex, previous/next occurrence, Filter rows) and a pager for console results (first/previous/next/last, page size). Later pages re-run the read-only query with a skip, which every driver reads past without decoding, so only one page crosses IPC; Last counts rows with the statement wrapped so a trailing line comment can't swallow the closing parenthesis.

Closes #1431

Co-authored-by: kidp2h <[email protected]>
Цей коміт міститься в:
Nguyen Thinh authored and GitHub committed 2026-11-05 12:54:15 +08:00
1 джерело 7856fabbfe
коміт 75c4ca0be8
22 files changed
+973 -97

No files matched your search

+1 -1
Переглянути файл
@@ -1233,7 +1233,7 @@ export const commands = {
dbSetManualCommit: (id: string, manual: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_set_manual_commit", { id, manual })),
dbFinishTransaction: (id: string, commit: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_finish_transaction", { id, commit })),
dbStatements: (id: string, text: string, from: number, to: number) => typedError<SqlStatement[], IdeError>(__TAURI_INVOKE("db_statements", { id, text, from, to })),
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, maxRows })),
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], skip: number, maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, skip, maxRows })),
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
+201 -28
Переглянути файл
@@ -4,20 +4,24 @@
* 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 { useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
import clsx from "clsx";
import { Button, Checkbox, Icon, IconButton, Input, Popup, Select, Tabs, T, type MenuItem } from "@/ui";
import { Button, Checkbox, Icon, IconButton, Input, Popup, SearchField, 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,
cellMatcher,
distinctValues,
findCells,
formatNumber,
rowMatches,
toggleFilterValue,
visibleRows,
type LocalFilter,
type LocalSort,
type SearchOptions,
} from "./resultView";
import { openDialog } from "./store";
import { hasLineBreak } from "./cellPreview";
@@ -83,9 +87,23 @@ export interface DataViewProps extends Pick<
/** The side panel shown (`null`: hidden). */
panel: DataPanel | null;
onPanelChange: (panel: DataPanel | null) => void;
/** The search bar is shown (the toolbar's search button, ⌘F in the grid). */
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
"aria-label"?: string;
}
const NO_SEARCH: SearchOptions = { caseSensitive: false, wholeWord: false, regex: false };
interface SearchState {
query: string;
options: SearchOptions;
/** Show only the rows with a match (IDEA's Filter rows). */
filterRows: boolean;
/** Index of the current match. */
current: number;
}
export function DataView(props: DataViewProps) {
const { columns, rowCount, value, fixedRows = rowCount, panel, onPanelChange } = props;
const columnKey = columns.map((c) => c.name).join("\u0000");
@@ -102,15 +120,49 @@ export function DataView(props: DataViewProps) {
const local = !props.onSort;
// Display row → loaded row; `null` keeps the identity (no filter, no local sort).
const order = useMemo(
const shaped = useMemo(
() =>
filter.size || (local && localSort)
? visibleRows(rowCount, value, filter, local ? localSort : null, fixedRows)
: null,
[rowCount, value, filter, local, localSort, fixedRows],
);
// Search covers the loaded rows only (the page shown).
const { searchOpen = false, onSearchOpenChange } = props;
const [search, setSearch] = useState<SearchState>({ query: "", options: NO_SEARCH, filterRows: false, current: 0 });
const searchInput = useRef<HTMLInputElement>(null);
const matcher = useMemo(
() => (searchOpen ? cellMatcher(search.query, search.options) : null),
[searchOpen, search.query, search.options],
);
const test = typeof matcher === "function" ? matcher : null;
const order = useMemo(() => {
if (!test || !search.filterRows) return shaped;
const base = shaped ?? Array.from({ length: rowCount }, (_, i) => i);
return base.filter((r) => rowMatches(r, columns.length, value, test));
}, [shaped, test, search.filterRows, rowCount, columns.length, value]);
const at = (r: number) => (order ? order[r]! : r);
const shown = order ? order.length : rowCount;
const matches = useMemo(
() => (test ? findCells(order ?? Array.from({ length: rowCount }, (_, i) => i), columns.length, value, test) : []),
[test, order, rowCount, columns.length, value],
);
const matchSet = useMemo(() => new Set(matches.map((m) => `${m.row}:${m.col}`)), [matches]);
const currentMatch = matches.length ? matches[Math.min(search.current, matches.length - 1)]! : null;
const step = (by: number) =>
matches.length &&
setSearch((s) => ({
...s,
current: (Math.min(s.current, matches.length - 1) + by + matches.length) % matches.length,
}));
const openSearch = () => {
onSearchOpenChange?.(true);
requestAnimationFrame(() => {
searchInput.current?.focus();
searchInput.current?.select();
});
};
const onSort = local
? (column: string) => {
@@ -129,32 +181,50 @@ export function DataView(props: DataViewProps) {
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">
{tx`${shown} of ${rowCount} rows`}
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
<T>Clear local filter</T>
</button>
</div>
<div className={styles.dataMain}>
{searchOpen && (
<GridSearchBar
inputRef={searchInput}
search={search}
onChange={(p) => setSearch((s) => ({ ...s, ...p, current: 0 }))}
count={matches.length}
active={currentMatch ? Math.min(search.current, matches.length - 1) : -1}
invalid={matcher === "invalid"}
onNext={() => step(1)}
onPrevious={() => step(-1)}
onClose={() => onSearchOpenChange?.(false)}
/>
)}
<div className={clsx(styles.fill, styles.gridHost)}>
<Grid
isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined}
currentMatch={currentMatch}
onFind={onSearchOpenChange && openSearch}
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">
{tx`${shown} of ${rowCount} rows`}
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
<T>Clear local filter</T>
</button>
</div>
)}
</div>
</div>
{filterAt && (
<FilterPopup
@@ -189,6 +259,109 @@ export function DataView(props: DataViewProps) {
);
}
/** IDEA's search bar above the grid: Match Case / Words / Regex, n/m, previous / next, Filter rows. */
function GridSearchBar(props: {
inputRef: RefObject<HTMLInputElement | null>;
search: SearchState;
onChange: (patch: Partial<Omit<SearchState, "current">>) => void;
count: number;
/** Zero-based current match; -1 when there is none. */
active: number;
invalid: boolean;
onNext: () => void;
onPrevious: () => void;
onClose: () => void;
}) {
const { search, count, invalid, active, inputRef, onChange, onNext, onPrevious, onClose } = props;
const none = !!search.query && !invalid && count === 0;
const toggle = (key: keyof SearchOptions) =>
onChange({ options: { ...search.options, [key]: !search.options[key] } });
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
} else if (e.key === "Enter" || e.key === "F3") {
e.preventDefault();
if (e.shiftKey) onPrevious();
else onNext();
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
const key =
e.code === "KeyC" ? "caseSensitive" : e.code === "KeyW" ? "wholeWord" : e.code === "KeyX" ? "regex" : null;
if (key) {
e.preventDefault();
toggle(key);
}
}
};
return (
<div className={styles.gridSearch} role="search" aria-label={tx("Search in results")} onKeyDown={onKeyDown}>
<SearchField
ref={inputRef}
autoFocus
className={styles.gridSearchField}
value={search.query}
onChange={(query) => onChange({ query })}
placeholder="Search in results"
aria-label="Search in results"
invalid={invalid || none}
toggles={[
{
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
pressed: search.options.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
{
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
pressed: search.options.wholeWord,
onToggle: () => toggle("wholeWord"),
},
{
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
pressed: search.options.regex,
onToggle: () => toggle("regex"),
},
]}
/>
<span className={clsx(styles.gridSearchCount, (invalid || none) && styles.gridSearchNone)} aria-live="polite">
{invalid ? tx("Bad regex") : !search.query ? "" : count === 0 ? tx("0 results") : `${active + 1}/${count}`}
</span>
<IconButton
icon="arrow-up"
title="Previous Occurrence"
shortcut="⇧↵"
size="small"
disabled={count === 0}
onClick={onPrevious}
/>
<IconButton
icon="arrow-down"
title="Next Occurrence"
shortcut="↵"
size="small"
disabled={count === 0}
onClick={onNext}
/>
<Checkbox
checked={search.filterRows}
onChange={(filterRows) => onChange({ filterRows })}
label={tx("Filter rows")}
/>
<span className={styles.spacer} />
<IconButton icon="close" title="Close" shortcut="⎋" size="small" onClick={onClose} />
</div>
);
}
function FilterPopup(props: {
anchor: HTMLElement;
column: string;
+74 -15
Переглянути файл
@@ -560,21 +560,6 @@
.gridHost {
position: relative;
}
/* IDEA's floating row count at the bottom of a result. */
.rowPill {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
padding: 3px 10px;
border: 1px solid var(--border-popup, var(--border));
border-radius: var(--radius-small);
background: var(--bg-popup, var(--bg-elev));
color: var(--fg-secondary);
font-size: var(--fs-small);
box-shadow: var(--shadow-popup, none);
pointer-events: none;
}
.resultSource {
color: var(--fg-muted);
font-size: var(--fs-small);
@@ -1260,3 +1245,77 @@
overflow-wrap: anywhere;
resize: vertical;
}
/* The grid beside the Record / Value / Aggregates panel, under its search bar. */
.dataMain {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* IDEA's search bar above a result grid. */
.gridSearch {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
font-size: var(--fs-ui);
}
.gridSearchField {
flex: 0 1 360px;
min-width: 160px;
}
.gridSearchCount {
font-family: var(--font-mono);
font-size: var(--fs-small);
color: var(--fg-subtle);
padding: 0 6px;
white-space: nowrap;
min-width: 56px;
}
.gridSearchNone {
color: var(--danger);
}
.cellMatch {
background: var(--bg-text-search-result);
}
.cellMatchCurrent {
background: var(--bg-text-search-result-current);
}
/* IDEA's floating pager at the bottom of a result: |< < 1-500 of 501+ > >| and the page size. */
.resultPager {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border: 1px solid var(--border-popup, var(--border));
border-radius: var(--radius-small);
background: var(--bg-popup, var(--bg-elev));
color: var(--fg-secondary);
font-size: var(--fs-small);
box-shadow: var(--shadow-popup, none);
white-space: nowrap;
}
.resultPagerRange {
padding: 0 6px;
}
.resultPagerCount {
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.resultPagerCount:disabled {
color: inherit;
cursor: default;
}
+59
Переглянути файл
@@ -2,6 +2,8 @@ import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { registerActions } from "@/platform/actions";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { DataView } from "./DataView";
import { Grid } from "./Grid";
import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview";
import { exportData } from "./export";
@@ -175,3 +177,60 @@ describe("multi-line database cells", () => {
expect(onEdit).not.toHaveBeenCalled();
});
});
describe("result grid search", () => {
const columns = [
{ name: "city", type: "text" },
{ name: "n", type: "int" },
];
const data = [
["Shanghai", "1"],
["Beijing", "2"],
["Shenzhen", "3"],
["Hangzhou", "4"],
];
const value = (r: number, c: number) => data[r]?.[c] ?? null;
it("opens the search bar on Ctrl+F and highlights the matches of the page", () => {
function Harness() {
const [open, setOpen] = useState(false);
return (
<DataView
columns={columns}
rowCount={4}
value={value}
panel={null}
onPanelChange={() => {}}
searchOpen={open}
onSearchOpenChange={setOpen}
/>
);
}
render(<Harness />);
fireEvent.keyDown(screen.getByRole("grid"), { key: "f", ctrlKey: true });
const input = screen.getByRole("textbox", { name: "Search in results" });
fireEvent.change(input, { target: { value: "sh" } });
const marked = () =>
screen
.getAllByRole("gridcell")
.filter((c) => /cellMatch/.test(c.className))
.map((c) => c.textContent);
expect(marked()).toEqual(["Shanghai", "Shenzhen"]);
expect(screen.getByText("1/2")).toBeTruthy();
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("2/2")).toBeTruthy();
// Match Case: "sh" no longer matches "Sh…".
fireEvent.click(screen.getByRole("button", { name: /Match Case/ }));
expect(marked()).toEqual([]);
expect(screen.getByText("0 results")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Match Case/ }));
// Filter rows keeps only the rows with a match.
fireEvent.click(screen.getByRole("checkbox", { name: "Filter rows" }));
expect(screen.getAllByRole("row").length - 1).toBe(2);
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByRole("search")).toBeNull();
expect(screen.getAllByRole("row").length - 1).toBe(4);
});
});
+22
Переглянути файл
@@ -51,6 +51,12 @@ export interface GridProps {
/** 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;
/** Cells matching the search bar's query. */
isMatch?: (row: number, col: number) => boolean;
/** The search bar's current match: selected and scrolled into view when it changes. */
currentMatch?: { row: number; col: number } | null;
/** ⌘F / Ctrl+F in the grid (opens the search bar). */
onFind?: () => void;
"aria-label"?: string;
}
@@ -154,6 +160,15 @@ export function Grid(props: GridProps) {
estimateSize: () => ROW_H,
overscan: 12,
});
const { currentMatch } = props;
useEffect(() => {
if (!currentMatch) return;
const { row, col } = currentMatch;
setSel({ r0: row, c0: col, r1: row, c1: col });
virtualizer.scrollToIndex(row, { align: "auto" });
// Follows the match only, not the virtualizer's identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMatch?.row, currentMatch?.col]);
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
@@ -211,6 +226,11 @@ export function Grid(props: GridProps) {
copy("tsv", false);
return;
}
if (mod && e.key.toLowerCase() === "f" && props.onFind) {
e.preventDefault();
props.onFind();
return;
}
if (mod && e.key.toLowerCase() === "a") {
e.preventDefault();
if (rowCount && columns.length) setSel({ r0: 0, c0: 0, r1: rowCount - 1, c1: columns.length - 1 });
@@ -466,6 +486,8 @@ export function Grid(props: GridProps) {
className={clsx(
styles.cell,
numeric[c] && styles.cellNumber,
props.isMatch?.(r, c) && styles.cellMatch,
currentMatch?.row === r && currentMatch.col === c && styles.cellMatchCurrent,
selected(r, c) && styles.cellSelected,
props.isModified?.(r, c) && styles.cellModified,
)}
+99 -5
Переглянути файл
@@ -7,16 +7,22 @@ import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { DbPlanNode } from "@/platform/ipc";
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
import { shortcutLabel } from "@/platform/keymap";
import { EmptyState, Icon, IconButton, Select, 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 { pageWindow } from "./resultView";
import { analyze } from "./sql/analyze";
import {
OUTPUT_TAB,
clearOutput,
closeResult,
countResult,
countable,
loadResultPage,
outcomeSummary,
pageable,
rerunResult,
sourceById,
targetLabel,
@@ -72,9 +78,88 @@ function queryTable(text: string, data: Pick<ExportData, "driver">): string {
return a.refs.find((r) => r.depth === 0 && r.kind === "table")?.name ?? "MY_TABLE";
}
const PAGE_SIZES = [10, 100, 500, 1000];
/** IDEA's pager at the bottom of a result: |< < 1-500 of 501+ > >| and the page size; paging runs the query again. */
function Pager({ tab }: { tab: ResultTab }) {
const r = tab.outcome.result!;
const page = tab.page ?? 0;
const pageSize = tab.pageSize ?? Math.max(r.rows.length, 1);
const state = { page, pageSize, rows: r.rows.length, hasMore: r.hasMore, total: tab.total ?? null };
const w = pageWindow(state);
const canPage = pageable(tab) && !tab.busy;
const go = (p: number, size = pageSize) => void loadResultPage(tab.id, p, size);
const goLast = async () => {
let lastPage = w.lastPage;
if (lastPage === null) {
const total = await countResult(tab.id);
if (total === null) return;
lastPage = pageWindow({ ...state, total }).lastPage;
}
if (lastPage !== null && lastPage !== page) go(lastPage);
};
const { first, last } = w;
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
return (
<div
className={styles.resultPager}
role="toolbar"
aria-label={tx("Result pages")}
title={outcomeSummary(tab.outcome)}
>
<IconButton
icon="page-first"
title="First Page"
size="small"
disabled={!canPage || page === 0}
onClick={() => go(0)}
/>
<IconButton
icon="chevron-left"
title="Previous Page"
size="small"
disabled={!canPage || page === 0}
onClick={() => go(page - 1)}
/>
<button
type="button"
className={clsx(styles.resultPagerRange, styles.resultPagerCount)}
disabled={w.total !== null || !canPage || !countable(tab)}
title={w.total === null ? tx("Count rows") : undefined}
onClick={() => void countResult(tab.id)}
>
{tx`${first}-${last} of ${total}`}
</button>
<IconButton
icon="chevron-right"
title="Next Page"
size="small"
disabled={!canPage || !r.hasMore}
onClick={() => go(page + 1)}
/>
<IconButton
icon="page-last"
title="Last Page"
size="small"
disabled={!canPage || (!r.hasMore && w.total !== null) || (w.total === null && !countable(tab))}
onClick={() => void goLast()}
/>
<Select
aria-label="Page size"
value={String(pageSize)}
options={sizes.map((v) => ({ value: String(v), label: String(v) }))}
onChange={(v) => go(0, Number(v))}
disabled={!canPage}
/>
</div>
);
}
function Result({ tab }: { tab: ResultTab }) {
const [view, setView] = useState<"table" | "chart">("table");
const [panel, setPanel] = useState<DataPanel | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
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]);
@@ -94,6 +179,15 @@ function Result({ tab }: { tab: ResultTab }) {
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
<span className={styles.toolbarSeparator} />
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
{view === "table" && (
<IconButton
icon="search"
title="Search"
shortcut={shortcutLabel("find.inFile")}
aria-pressed={searchOpen}
onClick={() => setSearchOpen(!searchOpen)}
/>
)}
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
@@ -121,12 +215,12 @@ function Result({ tab }: { tab: ResultTab }) {
value={value}
panel={panel}
onPanelChange={setPanel}
searchOpen={searchOpen}
onSearchOpenChange={setSearchOpen}
firstRowNumber={(tab.page ?? 0) * (tab.pageSize ?? 0) + 1}
aria-label={tab.title}
/>
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
{r.rows.length === 1 ? tx`${r.rows.length} row` : tx`${r.rows.length} rows`}
{r.hasMore ? ` ${tx("(limit reached)")}` : ""}
</div>
<Pager tab={tab} />
</div>
)}
</div>
+11
Переглянути файл
@@ -31,6 +31,7 @@ import { openDialog, sourceById, withConnection } from "./store";
import type { TableRef } from "./tableInput";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
const PAGE_SIZES = ["100", "500", "1000", "5000"];
@@ -46,6 +47,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
const [nonce, setNonce] = useState(0);
const [edits, setEdits] = useState<PendingEdits>(emptyEdits);
const [panel, setPanel] = useState<DataPanel | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
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);
@@ -254,6 +256,13 @@ export function TableEditor({ input }: { input: EditorInput }) {
disabled={!editable}
onClick={() => openDialog({ kind: "import", id: ref.id, schema: ref.schema, table: ref.table })}
/>
<IconButton
icon="search"
title="Search"
shortcut={shortcutLabel("find.inFile")}
aria-pressed={searchOpen}
onClick={() => setSearchOpen(!searchOpen)}
/>
<PanelToggle panel={panel} onChange={setPanel} />
</div>
<div className={styles.filterBar}>
@@ -322,6 +331,8 @@ export function TableEditor({ input }: { input: EditorInput }) {
value={value}
panel={panel}
onPanelChange={setPanel}
searchOpen={searchOpen}
onSearchOpenChange={setSearchOpen}
firstRowNumber={offset + 1}
rowState={(r) => (r >= loaded ? "inserted" : edits.deletes.has(r) ? "deleted" : null)}
isModified={(r, c) => !!edits.updates.get(r)?.has(names[c]!)}
+82
Переглянути файл
@@ -12,6 +12,10 @@ import {
import { dropSql } from "./objects";
import {
aggregates,
cellMatcher,
findCells,
pageFetch,
pageWindow,
chartSeries,
compareCells,
distinctValues,
@@ -219,3 +223,81 @@ describe("dropping schema objects", () => {
);
});
});
describe("grid search", () => {
const opts = { caseSensitive: false, wholeWord: false, regex: false };
it("matches cell text with Match Case, Words and Regex", () => {
const m = cellMatcher("shang", opts);
expect(typeof m).toBe("function");
const test = m as (v: string) => boolean;
expect(test("Shanghai")).toBe(true);
expect(test("Beijing")).toBe(false);
expect((cellMatcher("shang", { ...opts, caseSensitive: true }) as (v: string) => boolean)("Shanghai")).toBe(false);
const word = cellMatcher("23", { ...opts, wholeWord: true }) as (v: string) => boolean;
expect(word("23")).toBe(true);
expect(word("123")).toBe(false);
expect(word("a 23 b")).toBe(true);
const re = cellMatcher(String.raw`^\d\.\d$`, { ...opts, regex: true }) as (v: string) => boolean;
expect(re("2.5")).toBe(true);
expect(re("10")).toBe(false);
// A dot is literal without Regex.
expect((cellMatcher("2.5", opts) as (v: string) => boolean)("215")).toBe(false);
});
it("reports no query and a bad regex", () => {
expect(cellMatcher("", opts)).toBeNull();
expect(cellMatcher("(", { ...opts, regex: true })).toBe("invalid");
});
it("steps over astral characters on empty matches in Words mode", () => {
for (const query of ["x*", "|", "^", String.raw`\d*`, String.raw`\b`]) {
const m = cellMatcher(query, { ...opts, regex: true, wholeWord: true }) as (v: string) => boolean;
expect(() => m("a😀b")).not.toThrow();
expect(typeof m("😀")).toBe("boolean");
}
});
it("finds the matching cells row by row, skipping NULL", () => {
const m = cellMatcher("i", opts) as (v: string) => boolean;
expect(findCells([0, 1, 2, 3], 3, value, m)).toEqual([
{ row: 0, col: 1 },
{ row: 1, col: 1 },
{ row: 2, col: 1 },
]);
// Display rows are positions in `rows`.
expect(findCells([3, 1], 3, value, m)).toEqual([{ row: 1, col: 1 }]);
});
});
describe("result pages", () => {
it("describes the rows of a page, IDEA style", () => {
expect(pageWindow({ page: 0, pageSize: 500, rows: 500, hasMore: true, total: null })).toEqual({
first: 1,
last: 500,
total: null,
lastPage: null,
});
expect(pageWindow({ page: 1, pageSize: 500, rows: 1, hasMore: false, total: null })).toEqual({
first: 501,
last: 501,
total: 501,
lastPage: 1,
});
expect(pageWindow({ page: 0, pageSize: 100, rows: 100, hasMore: true, total: 1234 })).toMatchObject({
total: 1234,
lastPage: 12,
});
expect(pageWindow({ page: 0, pageSize: 100, rows: 0, hasMore: false, total: null })).toEqual({
first: 0,
last: 0,
total: 0,
lastPage: 0,
});
});
it("slices the rows of a page from a fetch of every row up to it", () => {
expect(pageFetch(0, 500)).toEqual({ maxRows: 500, skip: 0 });
expect(pageFetch(2, 100)).toEqual({ maxRows: 100, skip: 200 });
});
});
+113 -1
Переглянути файл
@@ -1,6 +1,7 @@
/**
* 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.
* of loaded rows, search in the grid, paging of console results, the
* Aggregates of a selection, and the series of the chart.
*/
import type { Cell } from "./model";
@@ -192,3 +193,114 @@ export function niceTicks(min: number, max: number, count = 5): number[] {
if (out.at(-1)! < hi) out.push(Number((out.at(-1)! + step).toPrecision(12)));
return out;
}
/** IDEA's search options of the grid's search bar. */
export interface SearchOptions {
caseSensitive: boolean;
wholeWord: boolean;
regex: boolean;
}
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
const WORD_CHAR = /[\p{L}\p{N}_$]/u;
/** A test of a cell's text against `query`; `null` without a query, `"invalid"` for a bad regex. */
export function cellMatcher(query: string, options: SearchOptions): ((text: string) => boolean) | null | "invalid" {
if (!query) return null;
const source = options.regex ? query : query.replace(REGEX_SPECIALS, "\\$&");
let re: RegExp;
try {
re = new RegExp(source, options.caseSensitive ? "gu" : "giu");
} catch {
return "invalid";
}
// Words: a match with no word character on either side (no lookbehind: older WebKit lacks it).
return (text) => {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const start = m.index;
const end = start + m[0].length;
if (end === start) {
// Step a whole code point: with the `u` flag a lastIndex inside a surrogate pair
// snaps back to the pair's start, which would match the same empty string forever.
re.lastIndex = start + ((text.codePointAt(start) ?? 0) > 0xffff ? 2 : 1);
if (!options.wholeWord) return true;
continue;
}
if (!options.wholeWord || (!WORD_CHAR.test(text[start - 1] ?? "") && !WORD_CHAR.test(text[end] ?? "")))
return true;
}
return false;
};
}
/** The cells matching `test`, row by row; `row` is the position in `rows` (display order). NULL never matches. */
export function findCells(
rows: readonly number[],
colCount: number,
value: (row: number, col: number) => Cell,
test: (text: string) => boolean,
): Array<{ row: number; col: number }> {
const out: Array<{ row: number; col: number }> = [];
rows.forEach((r, row) => {
for (let col = 0; col < colCount; col++) {
const v = value(r, col);
if (v !== null && test(v)) out.push({ row, col });
}
});
return out;
}
/** Whether any cell of loaded row `r` matches `test`. */
export function rowMatches(
r: number,
colCount: number,
value: (row: number, col: number) => Cell,
test: (text: string) => boolean,
): boolean {
for (let col = 0; col < colCount; col++) {
const v = value(r, col);
if (v !== null && test(v)) return true;
}
return false;
}
export interface PageState {
/** Zero-based page shown. */
page: number;
pageSize: number;
/** Rows on the page. */
rows: number;
/** More rows follow the page. */
hasMore: boolean;
/** Counted rows of the whole query, when known. */
total: number | null;
}
export interface PageWindow {
/** One-based numbers of the first and last row shown (0 and 0 on an empty page). */
first: number;
last: number;
/** Rows of the whole query: known once counted or on reaching the end. */
total: number | null;
/** Zero-based last page, when the total is known. */
lastPage: number | null;
}
/** What the pager shows ("1-500 of 501+"). */
export function pageWindow({ page, pageSize, rows, hasMore, total }: PageState): PageWindow {
const offset = page * pageSize;
const known = total ?? (hasMore ? null : offset + rows);
return {
first: rows ? offset + 1 : 0,
last: offset + rows,
total: known,
lastPage: known === null ? null : Math.max(0, Math.ceil(known / pageSize) - 1),
};
}
/** A page is read by running the query again: the backend reads past the rows before it and returns only the page. */
export function pageFetch(page: number, pageSize: number): { maxRows: number; skip: number } {
return { maxRows: pageSize, skip: page * pageSize };
}
+28
Переглянути файл
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isReadOnlyQuery } from "./readOnly";
describe("isReadOnlyQuery", () => {
it("accepts queries that only read", () => {
for (const text of [
"SELECT * FROM t",
"with x as (select 1) select * from x",
"VALUES (1), (2)",
"show tables",
"(select 1) union (select 2)",
])
expect(isReadOnlyQuery(text, "postgres"), text).toBe(true);
});
it("rejects statements that change data", () => {
for (const text of [
"DELETE FROM t RETURNING *",
"UPDATE t SET a = 1 RETURNING a",
"INSERT INTO t VALUES (1) RETURNING id",
"with d as (delete from t returning *) select * from d",
"select * into t2 from t",
"select * from t for update",
"select nextval('s')",
])
expect(isReadOnlyQuery(text, "postgres"), text).toBe(false);
});
});
+15
Переглянути файл
@@ -0,0 +1,15 @@
import type { DbDriver } from "@/platform/ipc";
import { lex } from "./lexer";
/** Statements that only read. */
const READ_VERBS = new Set(["SELECT", "WITH", "VALUES", "TABLE", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA"]);
/** Words that make a reading statement write (data-modifying CTEs, SELECT INTO, row locks, sequences). */
const WRITES = new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "INTO", "NEXTVAL", "SETVAL", "CALL", "EXEC", "EXECUTE"]);
/** Whether running `text` again only reads (paging a result re-runs its query). */
export function isReadOnlyQuery(text: string, driver: DbDriver): boolean {
const tokens = lex(text, driver).tokens;
const first = tokens.find((t) => t.text !== "(");
if (!first || first.kind !== "word" || !READ_VERBS.has(first.upper)) return false;
return !tokens.some((t) => t.kind === "word" && WRITES.has(t.upper));
}
+92 -15
Переглянути файл
@@ -39,6 +39,8 @@ import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
import { bindParams, findParams } from "./sql/params";
import { isReadOnlyQuery } from "./sql/readOnly";
import { pageFetch } from "./resultView";
import { tx } from "@/platform/i18n";
export const DB_TOOL_WINDOW = "database";
@@ -58,6 +60,11 @@ export interface ResultTab {
plan?: DbPlanNode[];
/** Being re-run. */
busy?: boolean;
/** Zero-based page of the result shown in `outcome` (IDEA's paging re-runs the query). */
page?: number;
pageSize?: number;
/** Rows of the whole query, once counted. */
total?: number | null;
}
/** One line of the consoles' Output log (IDEA's Database Console output). */
@@ -643,12 +650,19 @@ function promptOf(id: string): string {
return useDatabase.getState().completion[id]?.defaultSchema || sourceById(id)?.name || id;
}
/** Runs `statements` on `id`, logging to Output; errors stop the batch (backend). */
async function run(id: string, statements: SqlStatement[], request: RequestId): Promise<DbStatementOutcome[]> {
/**
* Runs `statements` on `id`, logging to Output; errors stop the batch (backend).
* Results keep `maxRows` rows after the first `skip`, which the backend reads past.
*/
async function run(
id: string,
statements: SqlStatement[],
request: RequestId,
maxRows = useDatabase.getState().maxRows,
skip = 0,
): 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, skip, maxRows)));
if (!wasConnected) log("info", "Connected");
for (const o of outcomes) {
log("statement", o.statement.text, `${promptOf(id)}>`);
@@ -791,12 +805,13 @@ export async function executeConsole(
const request = nextRequestId();
useDatabase.setState((s) => ({ running: { ...s.running, [path]: request } }));
try {
const outcomes = await run(id, statements, request);
const pageSize = useDatabase.getState().maxRows;
const outcomes = await run(id, statements, request, pageSize);
const tabs: ResultTab[] = outcomes
.filter((o) => o.result?.columns.length)
.map((outcome) => {
const n = nextTab++;
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome };
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome, page: 0, pageSize };
});
const failed = outcomes.some((o) => o.error);
useDatabase.setState((s) => ({
@@ -912,28 +927,90 @@ export async function openObjectDdl(id: string, schema: string, kind: DbObjectKi
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) {
patchResult(tabId, { busy: true });
try {
patch({ plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
patchResult(tabId, { plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
} catch (e) {
patch({ busy: false });
patchResult(tabId, { busy: false });
if (!isCancelled(e)) log("error", `[${targetLabel(tab.sourceId)}] ${message(e)}`);
}
return;
}
await loadResultPage(tabId, tab.page ?? 0, tab.pageSize ?? useDatabase.getState().maxRows, true);
}
/** Whether result tab `tab` can show other pages: running its query again only reads. */
export function pageable(tab: ResultTab): boolean {
return !tab.plan && isReadOnlyQuery(tab.outcome.statement.text, sourceById(tab.sourceId)?.driver ?? "sqlite");
}
function patchResult(tabId: number, p: Partial<ResultTab>) {
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
}
/**
* Shows page `page` of `pageSize` rows of result tab `tabId`: its query runs
* again and the backend returns only that page (IDEA's result paging).
* `rerun` also forgets the counted total.
*/
export async function loadResultPage(tabId: number, page: number, pageSize: number, rerun = false): Promise<void> {
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
if (!tab || tab.busy || tab.plan) return;
if (page > 0 && !pageable(tab)) return;
patchResult(tabId, { busy: true });
const { maxRows, skip } = pageFetch(page, pageSize);
try {
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId());
patch(outcome?.result ? { outcome, busy: false } : { busy: false });
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId(), maxRows, skip);
const r = outcome?.result;
if (r) {
const sizeChanged = pageSize !== tab.pageSize;
patchResult(tabId, {
outcome,
page,
pageSize,
busy: false,
...(rerun || sizeChanged ? { total: null } : {}),
});
} else patchResult(tabId, { busy: false });
if (outcome?.error) useDatabase.setState({ activeResult: OUTPUT_TAB });
} catch (e) {
patch({ busy: false });
patchResult(tabId, { busy: false });
if (!isCancelled(e)) log("error", message(e));
}
}
/** `SELECT COUNT(*)` over a query, for queries that can be a subquery. */
function countQuery(text: string): string | null {
if (!/^\s*(\(|select\b|with\b|values\b|table\b)/i.test(text)) return null;
// `)` on its own line: a trailing line comment (`… from t -- note`) would swallow it.
return `select count(*) from (\n${text.trim()}\n) q`;
}
/** Whether the rows of result tab `tab` can be counted. */
export function countable(tab: ResultTab): boolean {
return pageable(tab) && countQuery(tab.outcome.statement.text) !== null;
}
/** Counts the rows of result tab `tabId`'s whole query (the pager's "of 501+"). */
export async function countResult(tabId: number): Promise<number | null> {
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
if (!tab || tab.busy || !countable(tab)) return null;
const text = countQuery(tab.outcome.statement.text)!;
patchResult(tabId, { busy: true });
try {
const [outcome] = await run(tab.sourceId, [{ from: 0, to: text.length, text }], nextRequestId(), 1);
const total = Number(outcome?.result?.rows[0]?.[0]);
const known = outcome?.error || !Number.isFinite(total) ? null : total;
patchResult(tabId, { busy: false, ...(known === null ? {} : { total: known }) });
return known;
} catch (e) {
patchResult(tabId, { busy: false });
if (!isCancelled(e)) log("error", message(e));
return null;
}
}
/** Statements that only read (they leave a manual transaction clean). */
const READS = /^\s*(select|show|pragma|explain|describe|desc|values|table)\b/i;
+9 -1
Переглянути файл
@@ -6190,5 +6190,13 @@
"Plugin update available": "有插件可更新",
"{length} plugin updates available": "{length} 个插件可更新",
"Updated {updated} plugins": "已更新 {updated} 个插件",
"This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。"
"This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。",
"Search in results": "在结果中搜索",
"Bad regex": "正则表达式无效",
"Filter rows": "筛选行",
"Result pages": "结果分页",
"First Page": "第一页",
"Count rows": "统计行数",
"Last Page": "最后一页",
"{first}-{last} of {total}": "{first}-{last},共 {total}"
}
+2
Переглянути файл
@@ -225,6 +225,8 @@ const ICONS = {
refresh: "general/refresh",
settings: "general/settings",
help: "openidea/general/help",
"page-first": "openidea/general/pageFirst",
"page-last": "openidea/general/pageLast",
sliders: "openidea/general/sliders",
"graphql-variables": "openidea/general/graphqlVariables",
layout: "general/layout",
+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: First Page (bar and chevron left) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 4.5V11.5M11 11.5L7.5 8L11 4.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 267 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: Last Page (chevron right and bar) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 4.5V11.5M5 11.5L8.5 8L5 4.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 266 B