diff --git a/crates/ide-db/src/clickhouse.rs b/crates/ide-db/src/clickhouse.rs index 34e869dce..9d04f9c7d 100644 --- a/crates/ide-db/src/clickhouse.rs +++ b/crates/ide-db/src/clickhouse.rs @@ -150,6 +150,7 @@ impl ClickhousePool { self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed")) } + /// `max_rows`: the rows the server may send (the skipped ones included). fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder { let mut request = client .post(self.0.endpoint.clone()) @@ -177,6 +178,17 @@ impl ClickhousePool { } pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult { + self.execute_from(sql, 0, max_rows, cancel).await + } + + /// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them. + pub async fn execute_from( + &self, + sql: &str, + skip: usize, + max_rows: usize, + cancel: &CancellationToken, + ) -> IdeResult { if transaction_statement(sql) { return Err(transaction_error()); } @@ -188,7 +200,7 @@ impl ClickhousePool { }; let client = self.client()?; let query_id = uuid::Uuid::new_v4().to_string(); - let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned()); + let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned()); let started = Instant::now(); let fetch = async { let mut response = request.send().await.map_err(http_error)?; @@ -216,7 +228,7 @@ impl ClickhousePool { } (Vec::new(), Vec::new(), false) } else { - let mut reader = RowReader::new(max_rows); + let mut reader = RowReader::new(skip, max_rows); while let Some(chunk) = response.chunk().await.map_err(http_error)? { reader.push(&chunk)?; } @@ -309,11 +321,13 @@ impl ClickhousePool { type DecodedResult = (Vec, Vec>>, bool); -/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set -/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the -/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array +/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later +/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past +/// unparsed, so a grid limit bounds memory however large the block +/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array /// starts the exception ClickHouse writes when a query fails mid-stream. struct RowReader { + skip: usize, max_rows: usize, pending: Vec, names: Option>, @@ -324,8 +338,17 @@ struct RowReader { } impl RowReader { - fn new(max_rows: usize) -> Self { - Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None } + fn new(skip: usize, max_rows: usize) -> Self { + Self { + skip, + max_rows, + pending: Vec::new(), + names: None, + types: None, + rows: Vec::new(), + more: false, + exception: None, + } } fn push(&mut self, chunk: &[u8]) -> IdeResult<()> { @@ -356,9 +379,15 @@ impl RowReader { self.exception = Some(format!("{}\n", String::from_utf8_lossy(line))); return Ok(()); } - if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows { - self.more = true; - return Ok(()); + if self.names.is_some() && self.types.is_some() { + if self.skip > 0 { + self.skip -= 1; + return Ok(()); + } + if self.rows.len() >= self.max_rows { + self.more = true; + return Ok(()); + } } let Ok(values) = serde_json::from_slice::>>(text) else { self.exception = Some(format!("{}\n", String::from_utf8_lossy(line))); @@ -547,7 +576,11 @@ mod tests { } fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult { - let mut reader = RowReader::new(max_rows); + decode_from(body, 0, max_rows, chunk) + } + + fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult { + let mut reader = RowReader::new(skip, max_rows); for part in body.chunks(chunk) { reader.push(part)?; } @@ -586,6 +619,18 @@ mod tests { assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err()); } + #[test] + fn a_later_page_skips_rows_without_keeping_them() { + let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n"; + let page = |skip, max| { + let (_, rows, more) = decode_from(body, skip, max, 3).unwrap(); + (rows.into_iter().map(|r| r[0].clone().unwrap()).collect::>(), more) + }; + assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true)); + assert_eq!(page(4, 2), (vec!["5".to_owned()], false)); + assert_eq!(page(9, 2), (Vec::::new(), false)); + } + #[test] fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() { let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n"); diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 94bd01348..020dc0c6c 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -337,10 +337,13 @@ fn result_columns(row: &R) -> Vec { .collect() } -/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows -/// with their cells read as `$mode`. +/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip` +/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`. macro_rules! fetch { - ($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{ + ($exec:expr, $sql:expr, $max:expr, $mode:expr) => { + fetch!($exec, $sql, 0usize, $max, $mode) + }; + ($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{ use futures::StreamExt; let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec); let mut columns: Vec = Vec::new(); @@ -349,6 +352,7 @@ macro_rules! fetch { let mut has_more = false; let mut failure = None; let mut first: Option = None; + let mut to_skip: usize = $skip; while let Some(item) = stream.next().await { first.get_or_insert_with(Instant::now); match item { @@ -357,6 +361,10 @@ macro_rules! fetch { if columns.is_empty() { columns = result_columns(&row); } + if to_skip > 0 { + to_skip -= 1; + continue; + } if rows.len() >= $max { has_more = true; break; @@ -386,7 +394,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> { Ok(()) } -async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult { +/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding. +async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult { let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?; let first = Some(Instant::now()); let columns = stream @@ -399,8 +408,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult .collect(); let mut rows = Vec::new(); let mut has_more = false; + let mut to_skip = skip; while let Some(item) = stream.next().await { if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? { + if to_skip > 0 { + to_skip -= 1; + continue; + } if rows.len() >= max { has_more = true; break; @@ -734,7 +748,7 @@ impl Pool { let fetched: IdeResult = match self { Pool::SqlServer(p) => { let mut conn = p.get_owned().await.map_err(mssql_pool_err)?; - mssql_fetch(&mut conn, sql, max).await + mssql_fetch(&mut conn, sql, 0, max).await } _ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)), }; @@ -744,11 +758,23 @@ impl Pool { /// Runs one statement and keeps at most `max_rows` rows. Cancelling stops /// the statement on the server (PostgreSQL, MySQL) and drops the connection. pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult { + self.execute_from(sql, 0, max_rows, cancel).await + } + + /// [`execute`](Self::execute), reading past the first `skip` rows without keeping them + /// (a later page of a result: only that page is decoded and returned). + pub async fn execute_from( + &self, + sql: &str, + skip: usize, + max_rows: usize, + cancel: &CancellationToken, + ) -> IdeResult { if let Pool::Clickhouse(p) = self { - return p.execute(sql, max_rows, cancel).await; + return p.execute_from(sql, skip, max_rows, cancel).await; } let mut conn = Held::acquire(self).await?; - let r = conn.execute(self, sql, max_rows, cancel).await; + let r = conn.execute_from(self, sql, skip, max_rows, cancel).await; if cancel.is_cancelled() { conn.discard().await; return Err(IdeError::cancelled()); @@ -874,9 +900,22 @@ impl Held { sql: &str, max_rows: usize, cancel: &CancellationToken, + ) -> IdeResult { + self.execute_from(pool, sql, 0, max_rows, cancel).await + } + + /// [`execute`](Self::execute), reading past the first `skip` rows without keeping them + /// (see [`Pool::execute_from`]). + pub async fn execute_from( + &mut self, + pool: &Pool, + sql: &str, + skip: usize, + max_rows: usize, + cancel: &CancellationToken, ) -> IdeResult { if let Held::Clickhouse(c) = self { - return c.execute(sql, max_rows, cancel).await; + return c.execute_from(sql, skip, max_rows, cancel).await; } let started = Instant::now(); // `$id`: the query naming the server process, `$kill`: how another connection stops it. @@ -887,7 +926,7 @@ impl Held { None => None, }; let r = tokio::select! { - r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r, + r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r, () = cancel.cancelled() => Err(IdeError::cancelled()), }; if cancel.is_cancelled() { @@ -916,7 +955,7 @@ impl Held { Held::SqlServer(c) => { // Tiberius has no attention/cancel API; stop reading and discard the connection. tokio::select! { - r = mssql_fetch(c, sql, max_rows) => r?, + r = mssql_fetch(c, sql, skip, max_rows) => r?, () = cancel.cancelled() => return Err(IdeError::cancelled()), } } diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 71545ecbb..b549395d2 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -1009,7 +1009,20 @@ impl DbManager { max_rows: usize, cancel: &CancellationToken, ) -> IdeResult { - let r = self.run(id, statement, max_rows, cancel).await; + self.execute_from(id, statement, 0, max_rows, cancel).await + } + + /// [`execute`](Self::execute), reading past the first `skip` rows without keeping + /// them: a later page of a result, where only that page is decoded and returned. + pub async fn execute_from( + &self, + id: &str, + statement: &str, + skip: usize, + max_rows: usize, + cancel: &CancellationToken, + ) -> IdeResult { + let r = self.run(id, statement, skip, max_rows, cancel).await; let ran = match &r { Ok(_) => true, Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied), @@ -1044,7 +1057,7 @@ impl DbManager { /// The execution plan of `statement` (explained, not run). pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult> { let driver = self.pool(id).await?.driver(); - let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?; + let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?; plan::parse(driver, &result) } @@ -1052,13 +1065,14 @@ impl DbManager { &self, id: &str, statement: &str, + skip: usize, max_rows: usize, cancel: &CancellationToken, ) -> IdeResult { let pool = self.pool(id).await?; let session = self.sessions.lock().get(id).cloned(); let Some(session) = session else { - return pool.execute(statement, max_rows, cancel).await; + return pool.execute_from(statement, skip, max_rows, cancel).await; }; if pool.driver() == DbDriver::Clickhouse { return Err(IdeError::invalid("ClickHouse manual transactions are unsupported")); @@ -1070,7 +1084,7 @@ impl DbManager { *held = Some(conn); } let conn = held.as_mut().expect("opened above"); - let r = conn.execute(&pool, statement, max_rows, cancel).await; + let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await; if cancel.is_cancelled() { // The server rolls the transaction back with the connection. held.take().expect("opened above").discard().await; diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs index b800cd154..06bf741a0 100644 --- a/crates/ide-db/tests/sqlite.rs +++ b/crates/ide-db/tests/sqlite.rs @@ -1,7 +1,9 @@ //! Introspection, queries and table edits against a real SQLite file. use ide_core::{CancellationToken, IdeResult}; -use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey}; +use ide_db::{ + CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey, +}; async fn setup() -> (tempfile::TempDir, DbManager) { let tmp = tempfile::tempdir().unwrap(); @@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> { Ok(()) } +#[tokio::test] +async fn a_later_page_returns_only_its_rows() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + let sql = "SELECT id FROM users ORDER BY id"; + let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::>(); + + let second = db.execute_from("s1", sql, 1, 1, &c).await?; + assert_eq!(ids(&second), vec!["2"]); + assert!(second.has_more); + assert_eq!(second.columns.len(), 1); + + let last = db.execute_from("s1", sql, 2, 5, &c).await?; + assert_eq!(ids(&last), vec!["3"]); + assert!(!last.has_more); + + let past = db.execute_from("s1", sql, 9, 5, &c).await?; + assert!(past.rows.is_empty() && !past.has_more); + assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement"); + Ok(()) +} + #[tokio::test] async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> { let (_tmp, db) = setup().await; diff --git a/crates/ide-host/src/ops/db.rs b/crates/ide-host/src/ops/db.rs index 102bf2742..079542f26 100644 --- a/crates/ide-host/src/ops/db.rs +++ b/crates/ide-host/src/ops/db.rs @@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) { statements(&c.host, &c.window, &id, &text, from, to) }); d.add("db_execute", |c| async move { - let (request, id, statements, max_rows) = ( + let (request, id, statements, skip, max_rows) = ( c.arg::("request")?, c.arg::("id")?, c.arg::>("statements")?, + c.arg::>("skip")?.unwrap_or(0), c.arg::("maxRows")?, ); - execute(&c.host, &c.window, request, &id, statements, max_rows).await + execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await }); d.add("db_table_page", |c| async move { let (request, id, schema, table) = ( @@ -325,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to } /// Runs `statements` in order on data source `id`, stopping at the first -/// error. Cancel with `cancel_request(request)`. +/// error. Each result reads past its first `skip` rows (a later page) and keeps +/// `max_rows`. Cancel with `cancel_request(request)`. pub async fn execute( host: &Host, window: &str, request: RequestId, id: &str, statements: Vec, + skip: u32, max_rows: u32, ) -> IdeResult> { let db = host.project(window)?.db.clone(); let cancel = host.begin_request(request); let mut out = Vec::new(); for statement in statements { - match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await { + match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await { Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }), Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => { host.end_request(request); diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 0b9781c5b..247716196 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -250,9 +250,10 @@ pub async fn db_execute( request: RequestId, id: String, statements: Vec, + skip: u32, max_rows: u32, ) -> IdeResult> { - ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, max_rows).await + ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, skip, max_rows).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 8a972440c..080793a20 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1233,7 +1233,7 @@ export const commands = { dbSetManualCommit: (id: string, manual: boolean) => typedError(__TAURI_INVOKE("db_set_manual_commit", { id, manual })), dbFinishTransaction: (id: string, commit: boolean) => typedError(__TAURI_INVOKE("db_finish_transaction", { id, commit })), dbStatements: (id: string, text: string, from: number, to: number) => typedError(__TAURI_INVOKE("db_statements", { id, text, from, to })), - dbExecute: (request: RequestId, id: string, statements: SqlStatement[], maxRows: number) => typedError(__TAURI_INVOKE("db_execute", { request, id, statements, maxRows })), + dbExecute: (request: RequestId, id: string, statements: SqlStatement[], skip: number, maxRows: number) => typedError(__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(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })), dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })), dbConsolePath: (id: string) => typedError(__TAURI_INVOKE("db_console_path", { id })), diff --git a/src/features/database/DataView.tsx b/src/features/database/DataView.tsx index ec98a55e5..530637f1b 100644 --- a/src/features/database/DataView.tsx +++ b/src/features/database/DataView.tsx @@ -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({ query: "", options: NO_SEARCH, filterRows: false, current: 0 }); + const searchInput = useRef(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 (
-
- 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 && ( -
- {tx`${shown} of ${rowCount} rows`} - -
+
+ {searchOpen && ( + 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)} + /> )} +
+ 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 && ( +
+ {tx`${shown} of ${rowCount} rows`} + +
+ )} +
{filterAt && ( ; + search: SearchState; + onChange: (patch: Partial>) => 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) => { + 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 ( +
+ 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"), + }, + ]} + /> + + {invalid ? tx("Bad regex") : !search.query ? "" : count === 0 ? tx("0 results") : `${active + 1}/${count}`} + + + + onChange({ filterRows })} + label={tx("Filter rows")} + /> + + +
+ ); +} + function FilterPopup(props: { anchor: HTMLElement; column: string; diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css index e8d349d44..599d77da6 100644 --- a/src/features/database/Database.module.css +++ b/src/features/database/Database.module.css @@ -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; +} diff --git a/src/features/database/Grid.test.tsx b/src/features/database/Grid.test.tsx index 3898d1619..78e1742bf 100644 --- a/src/features/database/Grid.test.tsx +++ b/src/features/database/Grid.test.tsx @@ -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 ( + {}} + searchOpen={open} + onSearchOpenChange={setOpen} + /> + ); + } + render(); + 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); + }); +}); diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index 6b2f918e0..981020541 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -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, )} diff --git a/src/features/database/ResultsView.tsx b/src/features/database/ResultsView.tsx index 9cd40b564..ee3d34ddc 100644 --- a/src/features/database/ResultsView.tsx +++ b/src/features/database/ResultsView.tsx @@ -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): 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 ( +
+ go(0)} + /> + go(page - 1)} + /> + + go(page + 1)} + /> + void goLast()} + /> +