diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c07364e..3d70e6f5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,19 @@ jobs: distribution: "temurin" cache: maven + - name: Resolve build profiles + id: profiles + run: | + v=$(grep -m1 -oE '[^<]+' e2e/questdb/core/pom.xml | sed 's/.*>//') + if [[ "$v" == *-SNAPSHOT ]]; then + echo "list=build-binaries,local-client" >> "$GITHUB_OUTPUT" + else + echo "list=build-binaries" >> "$GITHUB_OUTPUT" + fi + - name: Build QuestDB run: - mvn clean package -f e2e/questdb/pom.xml -DskipTests -P build-binaries + mvn clean package -f e2e/questdb/pom.xml -DskipTests -P ${{ steps.profiles.outputs.list }} - name: Extract QuestDB run: diff --git a/.github/workflows/tests_with_context_path.yml b/.github/workflows/tests_with_context_path.yml index 2d6b90d42..00b4ea80d 100644 --- a/.github/workflows/tests_with_context_path.yml +++ b/.github/workflows/tests_with_context_path.yml @@ -22,9 +22,19 @@ jobs: distribution: "temurin" cache: maven + - name: Resolve build profiles + id: profiles + run: | + v=$(grep -m1 -oE '[^<]+' e2e/questdb/core/pom.xml | sed 's/.*>//') + if [[ "$v" == *-SNAPSHOT ]]; then + echo "list=build-binaries,local-client" >> "$GITHUB_OUTPUT" + else + echo "list=build-binaries" >> "$GITHUB_OUTPUT" + fi + - name: Build QuestDB run: - mvn clean package -f e2e/questdb/pom.xml -DskipTests -P build-binaries + mvn clean package -f e2e/questdb/pom.xml -DskipTests -P ${{ steps.profiles.outputs.list }} - name: Extract QuestDB run: diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 000000000..10df21230 --- /dev/null +++ b/BENCHMARK.md @@ -0,0 +1,124 @@ +# Result grid benchmarking harness + +How we A/B-compare scroll/keyboard performance of the two result grids — the +legacy `grid.js` and the React `ResultGrid` — during the migration. Latest +numbers live in [BENCHMARK_RESULTS.md](BENCHMARK_RESULTS.md). + +The harness is **dev-only**: every part is gated behind the `mock.pagination` +localStorage flag, so a normal session is unaffected and the code is inert until +you opt in. It is small enough to keep in the tree; this document is how to use +it (and how to strip it if we ever want to). + +## What it measures and why + +The thing we care about is **input → fully repainted *and correct***: the +wall-clock time from an action (a scroll, a key) landing to the frame where the +grid has actually settled into the right state. A step's timer stops only once +**all** of these hold (else it waits, up to a timeout, and the step is counted as +a failure): + +1. every visible cell shows the value for its own `(row, col)` — cells are seeded + self-describing (`r{row}c{col}`), so this catches blank, stale, *and* + column-misaligned cells; +2. the rendered cells cover the viewport's content area (no half-painted scroll); +3. for a keyboard move, the focused cell is at the exact expected `(row, col)` + with the value for that position. + +Raw FPS hides this — a grid can paint empty cells instantly and fill them late, +or a synthetic key can silently do nothing. Asserting the end state is what makes +the per-keystroke numbers trustworthy. + +Network/API latency dominates and varies run-to-run, so it would drown out the +render cost we're comparing. The harness removes that variable by serving a +**constant canned page at a fixed 10 ms latency** instead of hitting QuestDB. +Both grids go through the same `paginationFn` and the same `setData`, so the mock +applies to both unchanged. + +## Part A — the mock data source + +Two pieces, both already in the tree: + +1. [`src/scenes/Result/benchmarkMock.ts`](src/scenes/Result/benchmarkMock.ts) — + synthesises a result of any `rows × cols` from one canned page (built once, + served for every fetch) and exposes `isMockPagination`, `seedMock`, + `mockPaginate`. Cell values are **self-describing** (`r{row}c{col}`) so the + runner can assert each rendered cell holds the value for its own position. +2. Three small hooks in + [`src/scenes/Result/index.tsx`](src/scenes/Result/index.tsx), all guarded by + `isMockPagination()`: + - `paginationFn` short-circuits to `mockPaginate` (serves canned pages to + **both** grids). + - an effect publishes `window.__benchSeed(rows, cols)`, which seeds either + grid via `gridRef.setData(...)` — no real query needed. + +Enable it at runtime (no rebuild): + +```js +localStorage.setItem("mock.pagination", "true") +// reload, then run any query once (e.g. `select 1`) to mount the result pane — +// window.__benchSeed appears once the grid is mounted. +``` + +`localStorage.removeItem("mock.pagination")` restores normal fetching. + +## Part B — the measurement script + +[`e2e/benchmark/gridBench.js`](e2e/benchmark/gridBench.js) is a grid-agnostic, +in-page runner. It detects which grid is mounted and drives the right DOM and key +transport: + +| | new `ResultGrid` | legacy `grid.js` | +|---|---|---| +| viewport | `[data-hook="grid-viewport"]` | `.qg-viewport` | +| cell | `[data-hook="grid-cell"]` | `.qg-c` | +| active cell | `[aria-selected="true"]` + `cell-{row}-{col}` id | `.qg-c-active` + `.columnIndex` / parent `.rowIndex` | +| key target | `[role="grid"]` (React synthetic key) | `.qg-canvas` (`keyCode`) | + +Paste the file's contents into the console (or inject via `page.evaluate`) to +define `window.__gridBench`, then: + +```js +await window.__gridBench.run("vscroll_1m") // → { median, p95, min, max, total, failures, ... } +window.__gridBench.cases // all case keys +``` + +Each `run(key)` seeds the matching `rows × cols`, waits for the grid to fill, +drives the case asserting the end state of every step (focused cell index + +value, and all visible cells correct), and returns median / p95 / min / max / +total settle times plus a **`failures`** count (a step whose assertion never held +within the timeout). `failures` should be `0`; a non-zero count with `sampleFail` +means the run is not trustworthy. + +### The seven cases + +| key | what it drives | data | +|---|---|---| +| `vscroll_1m` | 100 randomized vertical scrolls | 1,000,000 × 20 | +| `hscroll_10k` | 100 randomized horizontal scrolls | 2,000 × 10,000 | +| `homeend_cols` | 100 End→Home combinations (200 presses) | 2,000 × 10,000 | +| `pagedn_10k` | PageDown ×100 then PageUp ×100 | 10,000 × 20 | +| `corners_1m_10k` | bottom-right → top-left corner jumps via shortcuts, ×100 | 1,000,000 × 10,000 | +| `arrow_right_1k` | 999 ArrowRight presses through the columns | 2,000 × 1,000 | +| `arrow_down_1k` | 999 ArrowDown presses through the rows | 1,000 × 20 | + +## Part C — comparing the two grids + +The grid is chosen by the `feature.new.grid` flag, settable from the URL: + +1. Same window size and the same machine for both runs (viewport size changes the + visible-cell count and thus the numbers). +2. **New grid:** open `http://localhost:9999/?useNewGrid=1`. **Legacy grid:** + `http://localhost:9999/?useNewGrid=0`. The param persists the flag and is then + stripped from the URL. +3. In each: `localStorage.setItem("mock.pagination", "true")`, reload, run any + query once, inject `gridBench.js`, then run each case and record the row. + +The numbers in [BENCHMARK_RESULTS.md](BENCHMARK_RESULTS.md) were collected this +way, driving the running dev server with the Playwright browser. + +## Removing the harness (if ever needed) + +Delete [`src/scenes/Result/benchmarkMock.ts`](src/scenes/Result/benchmarkMock.ts), +the three `isMockPagination()`-guarded hooks and the two imports in +[`src/scenes/Result/index.tsx`](src/scenes/Result/index.tsx), and +[`e2e/benchmark/`](e2e/benchmark/). `yarn typecheck && yarn lint` should be clean. diff --git a/BENCHMARK_RESULTS.md b/BENCHMARK_RESULTS.md new file mode 100644 index 000000000..e7fa1bb3d --- /dev/null +++ b/BENCHMARK_RESULTS.md @@ -0,0 +1,171 @@ +# Result grid benchmark results — legacy `grid.js` vs `ResultGrid` + +Generated by the harness in [BENCHMARK.md](BENCHMARK.md) using +[`e2e/benchmark/gridBench.js`](e2e/benchmark/gridBench.js) and the +`mock.pagination` data source ([`src/scenes/Result/benchmarkMock.ts`](src/scenes/Result/benchmarkMock.ts)). + +## Setup + +| | | +|---|---| +| Date | 2026-06-15 | +| Grids | legacy `grid.js` (`?useNewGrid=0`) vs React `ResultGrid` (`?useNewGrid=1`) | +| Viewport | 1600 × 900 window; grid viewport ≈ 1510 × 340–420 px (~10–14 visible rows) | +| Data | synthetic, self-describing cells `r{row}c{col}`, served from a constant canned page at a fixed 10 ms latency (zero network/API variance) | +| Metric | **input → fully repainted *and correct*** (see below) | + +## What "settled" means here — every step is asserted + +A step's timer stops only on the frame where **all** of these hold (otherwise it +keeps waiting, up to a 4 s timeout, and the step is counted as a **failure**): + +1. **Every visible cell shows the value for its own `(row, col)`.** Cells are + seeded as `r{row}c{col}`, so this single check catches blank, stale, *and* + column-misaligned cells. +2. **The rendered cells cover the viewport's content area** — a half-painted + scroll doesn't count as done. +3. **For keyboard moves, the focused cell is at the exact expected `(row, col)`** + and holds the value for that position. + +So these numbers are *time to a verified-correct repaint*, not just "a frame went +by." **Across both grids and all 7 cases (~5,600 asserted steps), there were 0 +failures** — every scroll/keystroke landed correctly. + +### How to read the numbers + +- **median / p95 / min / max** are per-step settle latencies (lower is better). +- **total** is the summed settle time; step counts match between the two grids + for every case, so it's directly comparable. +- Two effects are baked into both grids equally and are not pure render: + - **Paging debounce + latency** (~75 ms + 10 ms) on a scroll/jump into an + unloaded region — dominates the vertical-scroll and corner medians. + - **Settle floor** of ~one animation frame (~8 ms here) for a move needing no + fetch and no new columns. + +## Summary (median ms, lower is better) + +| Case | Legacy | ResultGrid | Winner | +|---|---:|---:|:--| +| Randomized vertical scroll — 1,000,000 rows (×100) | 99.7 | 107.9 | ≈ even (legacy +8%) | +| **Randomized horizontal scroll — 10,000 columns (×100)** | 114.0 | **18.8** | **ResultGrid 6.1×** | +| **Home / End across 10,000 columns (100 combinations)** | 150.6 | **30.8** | **ResultGrid 4.9×** | +| PageDown ×100 then PageUp ×100 — 10,000 rows | 8.3 | 8.8 | ≈ even | +| **Corner jumps — bottom-right → top-left (×100, 1M × 10k)** | 263.0 | **132.0** | **ResultGrid 2.0×** | +| **Right arrow through 1,000 columns (×999)** | 17.8 | **8.3** | **ResultGrid 2.1×** | +| Down arrow through 1,000 rows (×999) | 8.3 | 8.3 | ≈ even | + +**Takeaway:** vertical paging and single-row stepping are tied. Everything that +touches **columns** — horizontal scroll, Home/End, corner jumps, even +column-by-column arrow stepping — is markedly faster on `ResultGrid` because it +virtualizes columns; the legacy grid renders the whole visible column band. + +--- + +## Per-case results + +### 1. Randomized vertical scroll — 1,000,000 rows (20 columns, 100 scrolls) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 100 | 99.7 | 104.3 | 7.3 | 107.2 | 9917.0 | 0 | +| `ResultGrid` | 100 | 107.9 | 110.6 | 14.1 | 111.3 | 10676.5 | 0 | + +Both dominated by the load debounce + latency on each jump into a new page; the +`min` rows are scrolls that land inside an already-cached page. + +### 2. Randomized horizontal scroll — 10,000 columns (2,000 rows, 100 scrolls) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 100 | 114.0 | 150.0 | 98.7 | 179.1 | 11914.3 | 0 | +| `ResultGrid` | 100 | **18.8** | **22.9** | 16.6 | 29.1 | **1929.9** | 0 | + +No fetch (every row already holds all columns), so this is pure horizontal render +cost. `ResultGrid` virtualizes columns; legacy lays out every column in the +viewport band — ~6.1× slower at the median. + +### 3. Home / End across 10,000 columns — 100 End→Home combinations (2,000 rows, 200 presses) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 200 | 150.6 | 177.9 | 128.9 | 221.8 | 30291.9 | 0 | +| `ResultGrid` | 200 | **30.8** | **35.2** | 23.9 | 62.7 | **6205.7** | 0 | + +Each press jumps the focused cell from column 0 to column 9,999 (and back) and +repaints the destination band. `ResultGrid` is ~4.9× faster. + +### 4. PageDown ×100 then PageUp ×100 — 10,000 rows (20 columns, 200 presses) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 200 | 8.3 | 11.1 | 5.1 | 19.5 | 1685.2 | 0 | +| `ResultGrid` | 200 | 8.8 | 11.1 | 4.9 | 18.6 | 1833.9 | 0 | + +100 PageDowns (~1,200 rows) then 100 PageUps, mostly inside a loaded region. +Effectively tied at the median; `ResultGrid` takes an extra frame on the page +that crosses a fetch boundary (higher max). The page step is calibrated from +the first move and then asserted exactly on every subsequent press. + +### 5. Corner jumps — bottom-right → top-left ×100 (1,000,000 rows × 10,000 columns, 200 presses) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 200 | 263.0 | 314.6 | 233.8 | 399.0 | 53497.2 | 0 | +| `ResultGrid` | 200 | **132.0** | **140.5** | 123.2 | 159.8 | **26526.5** | 0 | + +The heaviest case: every jump moves both axes at once (tail-row fetch + a +10,000-column-wide repaint). `ResultGrid` is ~2.0× faster and far more consistent +(tighter p95/max). + +> Shortcuts used: `ResultGrid` reaches a corner with one chord (`Ctrl+End` / +> `Ctrl+Home`). The legacy grid has no single corner chord, so the harness sends +> its equivalent — a column key then a `Cmd`+arrow row key (`End`→`Cmd+↓`, +> `Home`→`Cmd+↑`) — and asserts the focused cell lands exactly on the corner. + +### 6. Right arrow through 1,000 columns (2,000 rows, 999 presses) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 999 | 17.8 | 21.1 | 14.9 | 33.8 | 18104.8 | 0 | +| `ResultGrid` | 999 | **8.3** | **9.7** | 5.9 | 14.9 | **8322.3** | 0 | + +With the assertion verifying the focus actually advanced one column each press, +the legacy grid's per-column cost shows: ~18 ms vs the new grid's ~8 ms (a single +React focus move + a virtualized column render). ~2.1× faster. + +### 7. Down arrow through 1,000 rows (20 columns, 999 presses) + +| Grid | steps | median | p95 | min | max | total | failures | +|---|---:|---:|---:|---:|---:|---:|---:| +| legacy `grid.js` | 999 | 8.3 | 9.4 | 6.3 | 21.7 | 8318.5 | 0 | +| `ResultGrid` | 999 | 8.3 | 9.4 | 4.8 | 165.5 | 8604.6 | 0 | + +Single-row vertical stepping is one frame on both grids — identical at the median +(the new grid's lone `max` outlier is a single GC/layout hitch, not a trend). + +--- + +## Caveats + +- **Column count drives the wide-result deltas.** The self-describing values are + short, so columns are narrow (~70 px) and ~20 are visible at once — which is + what makes cases 2/3/5/6 stress column rendering so hard. With wider columns + (fewer visible), the gap shrinks: an earlier run with mixed-width data showed + horizontal scroll at 28.9 vs 16.4 ms (1.8×) instead of 6.1×. The *direction* is + the same; the *magnitude* scales with visible-column count. +- **Debounce-bound cases.** Vertical-scroll and corner medians are mostly the + ~75 ms load debounce + 10 ms mock latency, not paint. +- **Small viewport** (~10–14 visible rows); a taller pane renders more cells per + frame and would widen the deltas further. +- **Virtual-row mapping — same idea on both grids.** For 1,000,000 rows neither + grid maps the full height proportionally: both cap the scroll canvas at + 10,000,000 px (~333k rows at 30 px) and reach the head 1:1, then **leap to the + tail** when the scrollbar bottoms out, so the middle isn't addressable by + dragging on either. Legacy does it imperatively — `y += ΔscrollTop` with an + explicit "final leap to bottom" in `grid.js` (its `M = yMax/h` ratio is + computed but unused); `ResultGrid` does it with a pure `toAbsoluteIndex` map + (head 1:1 + a fixed 1,000-row tail) in `virtualRowMapping.ts`. The one thing + that genuinely differs is the **focus coordinate space**: the new grid's + keyboard focus row is the *virtual* row (≤ ~333k), the legacy grid's is the + *absolute* row (up to 999,999). The harness tracks the expected focus cell in + each grid's own space, so the corner-jump assertions are correct on both. diff --git a/e2e/benchmark/gridBench.js b/e2e/benchmark/gridBench.js new file mode 100644 index 000000000..90d4b95ee --- /dev/null +++ b/e2e/benchmark/gridBench.js @@ -0,0 +1,397 @@ +/* + * Result-grid A/B benchmark runner (dev-only). + * + * Measures "input -> fully repainted AND correct" wall-clock for the legacy + * grid.js and the React ResultGrid under the same synthetic data, so the two can + * be compared. Grid-agnostic: it detects the mounted grid and drives the right + * DOM/key transport (new grid: [data-hook=...] + [role=grid]; legacy: .qg-* + + * keyCode on .qg-canvas). + * + * Each step's settle is only accepted once ALL of these hold (else it keeps + * waiting up to the timeout and the step is marked failed): + * 1. every visible cell shows the value for its own (row, col) — the mock + * seeds self-describing "r{row}c{col}" values, so this catches blank, + * stale, or column-misaligned cells; + * 2. the rendered cells cover the viewport's content area (no half-painted + * scroll counts as done); + * 3. for keyboard moves, the focused cell is at the EXPECTED (row, col) and + * holds the expected value. + * + * Requires window.__benchSeed (mock.pagination flag). See BENCHMARK.md. + * + * Usage: await window.__gridBench.run("vscroll_1m") -> stats incl. `failures` + */ +;(() => { + const $ = (s) => document.querySelector(s) + const isNewGrid = () => !!$('[data-hook="grid-viewport"]') + const viewport = () => $('[data-hook="grid-viewport"]') || $(".qg-viewport") + const keyTarget = () => + isNewGrid() ? $('[role="grid"]') : $(".qg-viewport .qg-canvas") + const cellSel = () => (isNewGrid() ? '[data-hook="grid-cell"]' : ".qg-c") + const activeSel = () => + isNewGrid() ? '[data-hook="grid-cell"][aria-selected="true"]' : ".qg-c-active" + const raf = () => new Promise((r) => requestAnimationFrame(() => r())) + + const PAGE = 1000 + const MAX_VIRTUAL_ROWS = Math.floor(10_000_000 / 30) + + // The canned page repeats every PAGE rows, so absolute row R shows row R % PAGE. + const expectedText = (absRow, col) => `r${((absRow % PAGE) + PAGE) % PAGE}c${col}` + + // { col, absRow, focusRow } for a cell. focusRow is the row index in the grid's + // own focus space (new grid: virtual row from the id; legacy: absolute row). + const cellCoord = (cell) => { + if (isNewGrid()) { + const m = /^cell-(\d+)-(\d+)$/.exec(cell.id || "") + if (!m) return null + const rowEl = cell.closest('[role="row"]') + const aria = rowEl ? parseInt(rowEl.getAttribute("aria-rowindex"), 10) : NaN + return { focusRow: +m[1], col: +m[2], absRow: aria - 2 } + } + const absRow = cell.parentElement ? cell.parentElement.rowIndex : NaN + return { focusRow: absRow, col: cell.columnIndex, absRow } + } + + const visibleCells = () => { + const vp = viewport() + if (!vp) return [] + const vr = vp.getBoundingClientRect() + return [...vp.querySelectorAll(cellSel())].filter((c) => { + const r = c.getBoundingClientRect() + return ( + r.bottom > vr.top && + r.top < vr.bottom && + r.right > vr.left && + r.left < vr.right + ) + }) + } + + // Diagnostic for the most recent failed predicate check. + let lastFail = "" + + const visibleAllCorrect = () => { + const vp = viewport() + if (!vp) return (lastFail = "no viewport"), false + const vr = vp.getBoundingClientRect() + const cells = visibleCells() + if (!cells.length) return (lastFail = "no cells"), false + let maxRight = -Infinity + let maxBottom = -Infinity + for (const c of cells) { + const co = cellCoord(c) + if (!co || Number.isNaN(co.absRow)) return (lastFail = "bad coord"), false + const exp = expectedText(co.absRow, co.col) + const got = c.textContent.trim() + if (got !== exp) + return (lastFail = `cell(${co.absRow},${co.col})="${got}" exp="${exp}"`), false + const r = c.getBoundingClientRect() + if (r.right > maxRight) maxRight = r.right + if (r.bottom > maxBottom) maxBottom = r.bottom + } + if (maxRight < vr.left + vp.clientWidth - 4) return (lastFail = "x-coverage"), false + if (maxBottom < vr.top + vp.clientHeight - 4) return (lastFail = "y-coverage"), false + return true + } + + const readActive = () => { + const a = $(activeSel()) + if (!a) return null + const co = cellCoord(a) + return co ? { ...co, text: a.textContent.trim() } : null + } + + // Run `act`, then wait until the grid is fully repainted-and-correct. When + // `exp` is given, the focused cell must also be at exp.{row,col} with the + // value for its position. Returns { ms, ok, reason }. + const settleAfter = async (act, exp, timeout = 4000) => { + const t0 = performance.now() + act() + await raf() + let reason = "timeout" + while (performance.now() - t0 < timeout) { + if (visibleAllCorrect()) { + if (!exp) return { ms: performance.now() - t0, ok: true } + const a = readActive() + if ( + a && + a.focusRow === exp.row && + a.col === exp.col && + a.text === expectedText(a.absRow, a.col) + ) + return { ms: performance.now() - t0, ok: true } + reason = a + ? `active(${a.focusRow},${a.col})="${a.text}" exp(${exp.row},${exp.col})` + : "no active cell" + } else { + reason = lastFail + } + await raf() + } + return { ms: performance.now() - t0, ok: false, reason } + } + + const dispatchNew = (key, mods) => + keyTarget().dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...mods }), + ) + const dispatchOld = (keyCode, mods = {}) => { + const canvas = keyTarget() + const fire = (kc, type = "keydown") => { + const e = new KeyboardEvent(type, { bubbles: true, cancelable: true }) + Object.defineProperty(e, "keyCode", { get: () => kc }) + Object.defineProperty(e, "which", { get: () => kc }) + canvas.dispatchEvent(e) + } + if (mods.ctrl) fire(17) + if (mods.cmd) fire(91) + fire(keyCode) + if (mods.ctrl) fire(17, "keyup") + if (mods.cmd) fire(91, "keyup") + } + const KEY = { + right: () => (isNewGrid() ? dispatchNew("ArrowRight") : dispatchOld(39)), + down: () => (isNewGrid() ? dispatchNew("ArrowDown") : dispatchOld(40)), + home: () => (isNewGrid() ? dispatchNew("Home") : dispatchOld(36)), + end: () => (isNewGrid() ? dispatchNew("End") : dispatchOld(35)), + pageDown: () => (isNewGrid() ? dispatchNew("PageDown") : dispatchOld(34)), + pageUp: () => (isNewGrid() ? dispatchNew("PageUp") : dispatchOld(33)), + // Corner jumps: one Ctrl chord on the new grid; a column key then a Cmd row + // key on the legacy grid, which has no single corner chord. + topLeft: () => + isNewGrid() + ? dispatchNew("Home", { ctrlKey: true }) + : (dispatchOld(36), dispatchOld(38, { cmd: true })), + bottomRight: () => + isNewGrid() + ? dispatchNew("End", { ctrlKey: true }) + : (dispatchOld(35), dispatchOld(40, { cmd: true })), + } + + const lastCol = (cols) => cols - 1 + const focusLastRow = (rows) => + (isNewGrid() ? Math.min(rows, MAX_VIRTUAL_ROWS) : rows) - 1 + + // Click the top-left visible cell and return where focus actually landed. + const focusTopLeft = async () => { + const vp = viewport() + vp.scrollTop = 0 + vp.scrollLeft = 0 + await raf() + await raf() + const cells = visibleCells().sort((a, b) => { + const ra = a.getBoundingClientRect() + const rb = b.getBoundingClientRect() + return ra.top - rb.top || ra.left - rb.left + }) + if (cells[0]) cells[0].click() + await raf() + const a = readActive() + return a ? { row: a.focusRow, col: a.col } : { row: 0, col: 0 } + } + + const prng = (seed) => () => (seed = (seed * 9301 + 49297) % 233280) / 233280 + + const randomScroll = async (axis, steps) => { + const vp = viewport() + const rnd = prng(20240611) + const samples = [] + const fails = [] + for (let i = 0; i < steps; i++) { + const max = + axis === "vertical" + ? vp.scrollHeight - vp.clientHeight + : vp.scrollWidth - vp.clientWidth + const pos = Math.floor(rnd() * max) + const r = await settleAfter(() => { + if (axis === "vertical") vp.scrollTop = pos + else vp.scrollLeft = pos + vp.dispatchEvent(new Event("scroll")) + }, null) + samples.push(r.ms) + if (!r.ok) fails.push(r.reason) + } + return { samples, fails } + } + + // Alternate two [key, transition(cur)->cur] descriptors for `steps` presses. + const keyAlternate = async (cols, rows, moves, steps) => { + let cur = await focusTopLeft() + const samples = [] + const fails = [] + for (let i = 0; i < steps; i++) { + const move = moves[i % moves.length] + cur = move.next(cur, cols, rows) + const r = await settleAfter(move.key, cur) + samples.push(r.ms) + if (!r.ok) fails.push(r.reason) + } + return { samples, fails } + } + + const keyRepeat = async (cols, rows, move, steps) => { + let cur = await focusTopLeft() + const samples = [] + const fails = [] + for (let i = 0; i < steps; i++) { + cur = move.next(cur, cols, rows) + const r = await settleAfter(move.key, cur, 2000) + samples.push(r.ms) + if (!r.ok) fails.push(r.reason) + } + return { samples, fails } + } + + // 100 PageDown then 100 PageUp. The page size is calibrated from the first + // move (it differs slightly between the two grids), then asserted exactly. + const pageThrough = async (rows) => { + let cur = await focusTopLeft() + const flr = focusLastRow(rows) + const samples = [] + const fails = [] + let pageRows = null + for (let i = 0; i < 100; i++) { + let r + if (pageRows === null) { + const before = cur.row + r = await settleAfter(KEY.pageDown, null) + const a = readActive() + if (a) { + pageRows = a.focusRow - before + cur = { row: a.focusRow, col: a.col } + } + } else { + cur = { row: Math.min(cur.row + pageRows, flr), col: cur.col } + r = await settleAfter(KEY.pageDown, cur) + } + samples.push(r.ms) + if (!r.ok) fails.push(r.reason) + } + for (let i = 0; i < 100; i++) { + cur = { row: Math.max(cur.row - pageRows, 0), col: cur.col } + const r = await settleAfter(KEY.pageUp, cur) + samples.push(r.ms) + if (!r.ok) fails.push(r.reason) + } + return { samples, fails } + } + + const END = { key: KEY.end, next: (c, cols) => ({ row: c.row, col: lastCol(cols) }) } + const HOME = { key: KEY.home, next: (c) => ({ row: c.row, col: 0 }) } + const RIGHT = { + key: KEY.right, + next: (c, cols) => ({ row: c.row, col: Math.min(c.col + 1, lastCol(cols)) }), + } + const DOWN = { + key: KEY.down, + next: (c, cols, rows) => ({ row: Math.min(c.row + 1, focusLastRow(rows)), col: c.col }), + } + const BR = { + key: KEY.bottomRight, + next: (c, cols, rows) => ({ row: focusLastRow(rows), col: lastCol(cols) }), + } + const TL = { key: KEY.topLeft, next: () => ({ row: 0, col: 0 }) } + + const CASES = { + vscroll_1m: { + title: "Randomized vertical scroll — 1,000,000 rows", + rows: 1_000_000, + cols: 20, + run: () => randomScroll("vertical", 100), + }, + hscroll_10k: { + title: "Randomized horizontal scroll — 10,000 columns", + rows: 2_000, + cols: 10_000, + run: () => randomScroll("horizontal", 100), + }, + homeend_cols: { + title: "Home / End across 10,000 columns — 100 End/Home combinations", + rows: 2_000, + cols: 10_000, + run: (rows, cols) => keyAlternate(cols, rows, [END, HOME], 200), + }, + pagedn_10k: { + title: "PageDown ×100 then PageUp ×100 — 10,000 rows", + rows: 10_000, + cols: 20, + run: (rows) => pageThrough(rows), + }, + corners_1m_10k: { + title: "Corner jumps — bottom-right → top-left ×100 (1,000,000 × 10,000)", + rows: 1_000_000, + cols: 10_000, + run: (rows, cols) => keyAlternate(cols, rows, [BR, TL], 200), + }, + arrow_right_1k: { + title: "Right arrow through 1,000 columns", + rows: 2_000, + cols: 1_000, + run: (rows, cols) => keyRepeat(cols, rows, RIGHT, 999), + }, + arrow_down_1k: { + title: "Down arrow through 1,000 rows", + rows: 1_000, + cols: 20, + run: (rows, cols) => keyRepeat(cols, rows, DOWN, 999), + }, + } + + const stats = (samples) => { + const sorted = samples.slice().sort((a, b) => a - b) + const pct = (p) => + sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * (sorted.length - 1)))] + const round = (x) => Math.round(x * 10) / 10 + return { + steps: samples.length, + median: round(pct(50)), + p95: round(pct(95)), + min: round(sorted[0]), + max: round(sorted[sorted.length - 1]), + total: round(samples.reduce((a, b) => a + b, 0)), + } + } + + const seedAndReady = async (rows, cols) => { + if (typeof window.__benchSeed !== "function") + throw new Error("window.__benchSeed missing — set mock.pagination=true and reload") + window.__benchSeed(rows, cols) + const vp = viewport() + if (vp) { + vp.scrollTop = 0 + vp.scrollLeft = 0 + } + const t0 = performance.now() + while (performance.now() - t0 < 8000) { + await raf() + if (visibleAllCorrect()) return + } + throw new Error("grid not ready/correct after seed: " + lastFail) + } + + const run = async (key) => { + const spec = CASES[key] + if (!spec) throw new Error("unknown case: " + key) + await seedAndReady(spec.rows, spec.cols) + const vp = viewport() + vp.scrollTop = 0 + vp.scrollLeft = 0 + await raf() + await raf() + const suiteStart = performance.now() + const { samples, fails } = await spec.run(spec.rows, spec.cols) + return { + key, + title: spec.title, + grid: isNewGrid() ? "ResultGrid" : "legacy", + rows: spec.rows, + cols: spec.cols, + suiteMs: Math.round(performance.now() - suiteStart), + ...stats(samples), + failures: fails.length, + sampleFail: fails[0] || null, + } + } + + window.__gridBench = { run, cases: Object.keys(CASES), isNewGrid } +})() diff --git a/e2e/commands.js b/e2e/commands.js index 2b267fe8d..49bb9032d 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -127,24 +127,127 @@ Cypress.Commands.add("getByDataHook", (name) => cy.get(`[data-hook="${name}"]`)) Cypress.Commands.add("getByRole", (name) => cy.get(`[role="${name}"]`)) Cypress.Commands.add("getGrid", () => - cy.get(".qg-viewport .qg-canvas").should("be.visible"), + cy + .get("[data-hook='grid-viewport'] [data-hook='grid-canvas']") + .should("be.visible"), ) -Cypress.Commands.add("getGridViewport", () => cy.get(".qg-viewport")) +Cypress.Commands.add("getGridViewport", () => + cy.get("[data-hook='grid-viewport']"), +) Cypress.Commands.add("getGridRow", (n) => - cy.get(".qg-r").filter(":visible").eq(n), + cy.get("[data-hook='grid-row']").filter(":visible").eq(n), ) Cypress.Commands.add("getColumnName", (n) => - cy.get(".qg-header-name").eq(n).invoke("text"), + cy.get("[data-hook='grid-header-name']").eq(n).invoke("text"), ) Cypress.Commands.add("getGridCol", (n) => - cy.get(".qg-c").filter(":visible").eq(n), + cy.get("[data-hook='grid-cell']").filter(":visible").eq(n), +) + +Cypress.Commands.add("getGridRows", () => + cy.get("[data-hook='grid-row']").filter(":visible"), +) + +Cypress.Commands.add("getGridCellAt", (row, col) => + cy.get(`#cell-${row}-${col}`), +) + +Cypress.Commands.add("getActiveCell", () => + cy.get("[data-hook='grid-cell'][aria-selected='true']"), +) + +Cypress.Commands.add("getFrozenCells", () => + cy.get("[data-hook='grid-cell'][data-frozen='true']"), ) -Cypress.Commands.add("getGridRows", () => cy.get(".qg-r").filter(":visible")) +Cypress.Commands.add("getGridHeaderCopy", (n) => + cy.get("[role='columnheader']").eq(n).find(".header-copy-btn"), +) + +Cypress.Commands.add("gridToolbar", (name) => + cy.get(`[data-hook='grid-toolbar-${name}']`), +) + +Cypress.Commands.add("selectGridCell", (row, col) => { + const selector = `#cell-${row}-${col}` + cy.get(selector).click() + cy.get(selector).should("have.attr", "aria-selected", "true") +}) + +Cypress.Commands.add("gridKey", (keyOptions) => + cy.get("[role='grid']").trigger("keydown", { force: true, ...keyOptions }), +) + +Cypress.Commands.add("resizeColumn", (n, dx) => { + cy.get("[data-hook='grid-col-resizer']") + .filter(":visible") + .eq(n) + .then(($resizer) => { + const rect = $resizer[0].getBoundingClientRect() + const startX = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + cy.wrap($resizer).trigger("mousedown", { + button: 0, + clientX: startX, + clientY: y, + force: true, + }) + cy.get("body").trigger("mousemove", { + clientX: startX + dx, + clientY: y, + force: true, + }) + cy.get("body").trigger("mouseup", { + clientX: startX + dx, + clientY: y, + force: true, + }) + }) +}) + +Cypress.Commands.add("freezeColumnViaHandle", (dx) => { + cy.get("[data-hook='grid-freeze-handle']").then(($handle) => { + const rect = $handle[0].getBoundingClientRect() + const startX = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + cy.wrap($handle).trigger("mousedown", { + button: 0, + clientX: startX, + clientY: y, + force: true, + }) + cy.get("body").trigger("mousemove", { + clientX: startX + dx, + clientY: y, + force: true, + }) + cy.get("body").trigger("mouseup", { + clientX: startX + dx, + clientY: y, + force: true, + }) + }) +}) + +Cypress.Commands.add("dragFreezeHandleTo", (clientX) => { + cy.get("[data-hook='grid-freeze-handle']").then(($handle) => { + const rect = $handle[0].getBoundingClientRect() + const startX = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + cy.wrap($handle).trigger("mousedown", { + button: 0, + clientX: startX, + clientY: y, + force: true, + }) + cy.get("body").trigger("mousemove", { clientX, clientY: y, force: true }) + cy.get("body").trigger("mouseup", { clientX, clientY: y, force: true }) + }) +}) Cypress.Commands.add("typeQuery", (query) => cy.getEditor().realClick().type(query), diff --git a/e2e/questdb b/e2e/questdb index 5df7d4278..bc56a6cf3 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 5df7d42780e63d473638d8b5c32cbad6659fc8a4 +Subproject commit bc56a6cf31586697f6d1a1d90f00e64c4600db2c diff --git a/e2e/tests/console/grid.spec.js b/e2e/tests/console/grid.spec.js index 2f5a9f0ee..bd01f9a52 100644 --- a/e2e/tests/console/grid.spec.js +++ b/e2e/tests/console/grid.spec.js @@ -2,80 +2,603 @@ const rowHeight = 30 -const assertRowCount = () => { - cy.get(".qg-viewport").then(($el) => { - cy.getGridRows().should("have.length", Math.ceil($el.height() / rowHeight)) - }) -} +const threeColumnQuery = "select x a, x b, x c from long_sequence(20)" + +const distinctColumnQuery = + "select x a, x * 10 b, x * 100 c from long_sequence(20)" + +const readClipboard = () => + cy + .window() + .its("navigator.clipboard") + .then((clip) => clip.readText()) describe("questdb grid", () => { beforeEach(() => { cy.loadConsoleWithAuth() }) - it("when results empty", () => { - cy.typeQuery("select x from long_sequence(0)") - cy.runLine() - cy.getGridRows().should("have.length", 0) - }) + describe("rendering and pagination", () => { + it("when results empty", () => { + cy.typeQuery("select x from long_sequence(0)") + cy.runLine() + cy.getGridViewport().should("be.visible") + // Scoped from the viewport so the zero-length assertion doesn't wait for + // a grid-row that legitimately never appears. + cy.get("[data-hook='grid-row']").should("have.length", 0) + }) - it("when results have vertical scroll", () => { - cy.typeQuery(`select x from long_sequence(100)`) - cy.runLine() - cy.wait(100) - cy.getGridViewport().then(($el) => { - cy.getGridRows().should( - "have.length", - Math.ceil($el.height() / rowHeight), - ) + it("when results have vertical scroll", () => { + cy.typeQuery(`select x from long_sequence(100)`) + cy.runLine() + cy.wait(100) + + // The grid fills the viewport with the first rows... cy.getGridRow(0).should("contain", "1") + cy.getGridRows().should("have.length.greaterThan", 5) + + // ...and scrolling to the bottom brings the last row into view. + cy.getGridViewport().scrollTo("bottom") + cy.contains("[data-hook='grid-row']", "100").should("be.visible") }) - cy.getGridViewport().scrollTo("bottom") - cy.getGridViewport().then(($el) => { - const totalRows = Math.ceil($el.height() / rowHeight) - cy.getGridRows().should("have.length", totalRows) - cy.getGridRow(totalRows - 1).should("contain", "100") + it("multiple scrolls till the bottom", () => { + const rows = 1000 + const rowsPerPage = 128 + cy.typeQuery(`select x from long_sequence(${rows})`) + cy.runLine() + + for (let i = 0; i < rows; i += rowsPerPage) { + cy.getGridViewport().scrollTo(0, i * rowHeight) + cy.wait(100) + cy.getGrid() + .contains(i + 1) + .click() + } + + cy.getGridViewport().scrollTo("bottom") }) - }) - it("multiple scrolls till the bottom", () => { - const rows = 1000 - const rowsPerPage = 128 - cy.typeQuery(`select x from long_sequence(${rows})`) - cy.runLine() + it("multiple scrolls till the bottom with error", () => { + const rows = 1200 + cy.typeQuery(`select simulate_crash('P') from long_sequence(${rows})`) + cy.runLine() + + cy.getGridViewport().scrollTo(0, 999 * rowHeight) + cy.getCollapsedNotifications().should("contain", "1,200 rows in") - for (let i = 0; i < rows; i += rowsPerPage) { - cy.getGridViewport().scrollTo(0, i * rowHeight) + cy.getGridViewport().scrollTo("bottom") cy.wait(100) - cy.getGrid() - .contains(i + 1) - .click() - } + cy.getCollapsedNotifications().should( + "contain", + "simulated cairo exception", + ) + }) + + it("deep pages lazily load the correct value into the correct cell", () => { + // Given a result several pages deep (the seed page holds 1000 rows) + cy.typeQuery("select x a, x * 10 b from long_sequence(5000)") + cy.runLine() + cy.wait(100) + + // When scrolling far past the seed page so a later page lazily loads + cy.getGridViewport().scrollTo(0, 2500 * rowHeight) + + // Then row 2500 shows its true values in both columns, proving the page + // landed at the right rows and the right columns + cy.getGridCellAt(2500, 0).should("have.text", "2501") + cy.getGridCellAt(2500, 1).should("have.text", "25010") + + // And the last row of the final page is correct too + cy.getGridViewport().scrollTo("bottom") + cy.getGridCellAt(4999, 0).should("have.text", "5000") + cy.getGridCellAt(4999, 1).should("have.text", "50000") + }) + + it("loads two adjacent pages correctly across a page boundary", () => { + // Given a multi-page result + cy.typeQuery("select x from long_sequence(5000)") + cy.runLine() + cy.wait(100) + + // When the viewport straddles the 2000/3000 page boundary, forcing both + // pages to load together in a single fetch that is split between them + cy.getGridViewport().scrollTo(0, 2999 * rowHeight) + + // Then the rows on each side of the boundary show their true values — + // a mis-split would land the wrong page's data here + cy.getGridCellAt(2999, 0).should("have.text", "3000") + cy.getGridCellAt(3000, 0).should("have.text", "3001") + }) + }) + + describe("keyboard navigation", () => { + it("arrow keys move the active cell", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + + // When + cy.selectGridCell(0, 0) + cy.gridKey({ key: "ArrowRight" }) + cy.gridKey({ key: "ArrowDown" }) + + // Then + cy.getActiveCell().should("have.id", "cell-1-1") + }) + + it("Home and End jump to the row's first and last column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 1) + + // When / Then + cy.gridKey({ key: "End" }) + cy.getActiveCell().should("have.id", "cell-0-2") + + cy.gridKey({ key: "Home" }) + cy.getActiveCell().should("have.id", "cell-0-0") + }) + + it("Ctrl+Home and Ctrl+End jump to the grid corners", () => { + // Given + cy.typeQuery("select x from long_sequence(50)") + cy.runLine() + cy.selectGridCell(0, 0) + + // When / Then + cy.gridKey({ key: "End", ctrlKey: true }) + cy.getActiveCell().should("have.id", "cell-49-0") + + cy.gridKey({ key: "Home", ctrlKey: true }) + cy.getActiveCell().should("have.id", "cell-0-0") + }) + + it("PageDown and PageUp move by a viewport of rows", () => { + // Given + cy.typeQuery("select x from long_sequence(200)") + cy.runLine() + cy.selectGridCell(0, 0) + + // When + cy.gridKey({ key: "PageDown" }) + + // Then — moved well past a single row + cy.getActiveCell().should("not.have.id", "cell-0-0") + + // When / Then — back to the top + cy.gridKey({ key: "PageUp" }) + cy.getActiveCell().should("have.id", "cell-0-0") + }) + }) + + describe("copy", () => { + it("Ctrl+C copies the focused cell and pulses it", () => { + // Given + cy.typeQuery("select x from long_sequence(10)") + cy.runLine() + cy.selectGridCell(0, 0) + + // When + cy.realPress(["Control", "c"]) + + // Then + cy.getActiveCell().should("have.attr", "data-pulse", "true") + readClipboard().should("eq", "1") + }) + + it("the header copy button copies the column name", () => { + // Given + cy.typeQuery("select x from long_sequence(10)") + cy.runLine() + + // When + cy.getGridHeaderCopy(0).click({ force: true }) + + // Then + readClipboard().should("eq", "x") + }) + }) + + describe("yield focus to editor", () => { + it("F2 clears the selection and returns focus to the editor", () => { + // Given + cy.typeQuery("select x from long_sequence(10)") + cy.runLine() + cy.selectGridCell(0, 0) + cy.getActiveCell().should("exist") + + // When + cy.realPress("F2") + + // Then + cy.getActiveCell().should("not.exist") + cy.getEditor().find("textarea").should("be.focused") + }) + }) + + describe("move column to front", () => { + it("the '/' shortcut moves the focused column to the front", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 2) + + // When + cy.gridKey({ key: "/" }) + + // Then + cy.getColumnName(0).should("eq", "c") + }) + + it("the toolbar button moves the focused column to the front", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 2) + + // When + cy.gridToolbar("move-front").click() + + // Then + cy.getColumnName(0).should("eq", "c") + }) + + it("the toolbar button is disabled until a cell is selected", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + + // Then — nothing selected yet + cy.gridToolbar("move-front").should("be.disabled") + + // When + cy.selectGridCell(0, 0) + + // Then + cy.gridToolbar("move-front").should("not.be.disabled") + }) + + it("keeps each value under its own header when a column is already frozen", () => { + // Given a result with distinct per-column values and the left column (a) frozen + cy.typeQuery(distinctColumnQuery) + cy.runLine() + cy.gridToolbar("freeze").click() + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") - cy.getGridViewport().scrollTo("bottom") + // When the user moves the third column (c) to the front + cy.selectGridCell(0, 2) + cy.gridToolbar("move-front").click() + + // Then the frozen column stays first and the moved column follows it + cy.getColumnName(0).should("eq", "a") + cy.getColumnName(1).should("eq", "c") + cy.getColumnName(2).should("eq", "b") + + // And every cell still shows its own column's value, not a neighbour's + cy.getGridCellAt(0, 0).should("have.text", "1") + cy.getGridCellAt(0, 1).should("have.text", "100") + cy.getGridCellAt(0, 2).should("have.text", "10") + }) }) - it("multiple scrolls till the bottom with error", () => { - const rows = 1200 - cy.typeQuery(`select simulate_crash('P') from long_sequence(${rows})`) - cy.runLine() + describe("freeze left", () => { + it("the toolbar freezes and unfreezes the left column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + + // When + cy.gridToolbar("freeze").click() + + // Then + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") + cy.gridToolbar("freeze").should("have.attr", "data-selected", "true") + + // When + cy.gridToolbar("freeze").click() + + // Then + cy.getFrozenCells().should("have.length", 0) + cy.gridToolbar("freeze").should("have.attr", "data-selected", "false") + }) + + it("dragging the freeze handle freezes an additional column", () => { + // Given + cy.typeQuery("select x a, x b, x c, x d from long_sequence(20)") + cy.runLine() + cy.gridToolbar("freeze").click() + cy.getGridCellAt(0, 1).should("not.have.attr", "data-frozen") + + // When + cy.freezeColumnViaHandle(250) + + // Then + cy.getGridCellAt(0, 1).should("have.attr", "data-frozen", "true") + }) + + it("dragging the handle from an unfrozen grid freezes multiple columns", () => { + // Given a four-column result with no frozen columns + cy.typeQuery("select x a, x b, x c, x d from long_sequence(20)") + cy.runLine() + cy.getGridCellAt(0, 0).should("be.visible") + cy.getFrozenCells().should("have.length", 0) + cy.get("[data-hook='grid-freeze-handle']").should("exist") + + // When dragging the handle past the first two columns + cy.getGridCellAt(0, 1).then(($cell) => { + cy.dragFreezeHandleTo($cell[0].getBoundingClientRect().right) + }) + + // Then the first two columns are frozen and the third is not + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") + cy.getGridCellAt(0, 1).should("have.attr", "data-frozen", "true") + cy.getGridCellAt(0, 2).should("not.have.attr", "data-frozen") + + // When dragging the handle further to include the third column + cy.getGridCellAt(0, 2).then(($cell) => { + cy.dragFreezeHandleTo($cell[0].getBoundingClientRect().right) + }) + + // Then the first three columns are frozen and the fourth is not + cy.getGridCellAt(0, 2).should("have.attr", "data-frozen", "true") + cy.getGridCellAt(0, 3).should("not.have.attr", "data-frozen") + }) + + it("keeps the frozen column pinned while scrolling horizontally", () => { + // Given a result wide enough to scroll horizontally, left column frozen + const columns = Array.from({ length: 20 }, (_, i) => `x c${i}`).join(", ") + cy.typeQuery(`select ${columns} from long_sequence(10)`) + cy.runLine() + cy.gridToolbar("freeze").click() + cy.getGridCellAt(0, 0).should("have.attr", "data-frozen", "true") - cy.getGridViewport().scrollTo(0, 999 * rowHeight) - cy.getCollapsedNotifications().should("contain", "1,200 rows in") + let frozenLeft + cy.getGridCellAt(0, 0).then(($cell) => { + frozenLeft = $cell[0].getBoundingClientRect().left + }) - cy.getGridViewport().scrollTo("bottom") - cy.wait(100) - cy.getCollapsedNotifications().should( - "contain", - "simulated cairo exception", - ) + // When scrolling all the way to the right + cy.getGridViewport().scrollTo("right") + + // Then the grid scrolled, the frozen column stayed put, and the shadow showed + cy.getGridViewport().should(($vp) => { + expect($vp[0].scrollLeft).to.be.greaterThan(0) + }) + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().left).to.be.closeTo( + frozenLeft, + 2, + ) + }) + cy.get("[data-hook='grid-frozen-shadow']").should("be.visible") + }) + }) + + describe("column resize", () => { + it("dragging the separator widens the column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let startWidth + cy.getGridCellAt(0, 0).then(($cell) => { + startWidth = $cell[0].getBoundingClientRect().width + }) + + // When + cy.resizeColumn(0, 120) + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.greaterThan( + startWidth, + ) + }) + }) + + it("arrow keys on the separator resize the column", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let startWidth + cy.getGridCellAt(0, 0).then(($cell) => { + startWidth = $cell[0].getBoundingClientRect().width + }) + + // When + cy.get("[data-hook='grid-col-resizer']") + .filter(":visible") + .first() + .focus() + cy.realPress("ArrowRight") + cy.realPress("ArrowRight") + cy.realPress("ArrowRight") + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.greaterThan( + startWidth, + ) + }) + }) }) - it("copy cell into the clipboard", () => { - cy.typeQuery("select x from long_sequence(10)") - cy.runLine() - cy.getGridCol(0).type("{ctrl}c") - cy.getGridCol(0).should("have.class", "qg-c-active-pulse") + describe("reset layout", () => { + it("the toolbar reset restores the default column width", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let defaultWidth + cy.getGridCellAt(0, 0).then(($cell) => { + defaultWidth = $cell[0].getBoundingClientRect().width + }) + cy.resizeColumn(0, 150) + + // When + cy.gridToolbar("reset").click() + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + defaultWidth, + 2, + ) + }) + }) + + it("Ctrl+B resets the layout", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let defaultWidth + cy.getGridCellAt(0, 0).then(($cell) => { + defaultWidth = $cell[0].getBoundingClientRect().width + }) + cy.resizeColumn(0, 150) + + // When + cy.selectGridCell(0, 0) + cy.gridKey({ key: "b", ctrlKey: true }) + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + defaultWidth, + 2, + ) + }) + }) + }) + + describe("layout persistence", () => { + it("a resized column keeps its width after a re-run", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.resizeColumn(0, 150) + let widenedWidth + cy.getGridCellAt(0, 0).then(($cell) => { + widenedWidth = $cell[0].getBoundingClientRect().width + }) + + // When + cy.runLine() + + // Then + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + widenedWidth, + 2, + ) + }) + }) + + it("column order and freeze survive a re-run", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + cy.selectGridCell(0, 2) + cy.gridKey({ key: "/" }) + cy.getColumnName(0).should("eq", "c") + cy.gridToolbar("freeze").click() + + // When + cy.runLine() + + // Then + cy.getColumnName(0).should("eq", "c") + cy.getFrozenCells().should("have.length.greaterThan", 0) + }) + + it("reset clears the persisted layout for the next run", () => { + // Given + cy.typeQuery(threeColumnQuery) + cy.runLine() + let defaultWidth + cy.getGridCellAt(0, 0).then(($cell) => { + defaultWidth = $cell[0].getBoundingClientRect().width + }) + cy.resizeColumn(0, 150) + cy.gridToolbar("reset").click() + + // When + cy.runLine() + + // Then + cy.getColumnName(0).should("eq", "a") + cy.getGridCellAt(0, 0).should(($cell) => { + expect($cell[0].getBoundingClientRect().width).to.be.closeTo( + defaultWidth, + 2, + ) + }) + }) + }) + + describe("designated timestamp", () => { + const table = "grid_designated_ts" + + afterEach(() => { + cy.execQuery(`drop table if exists ${table}`) + }) + + it("colors only the designated timestamp column, not every timestamp column", () => { + // Given — ts is designated, ts2 is a plain timestamp column + cy.execQuery(`drop table if exists ${table}`) + cy.execQuery( + `create table ${table} (ts timestamp, ts2 timestamp, val long) timestamp(ts)`, + ) + cy.execQuery( + `insert into ${table} values('2024-01-01T00:00:00.000000Z','2024-01-01T00:00:00.000000Z',1)`, + ) + + // When + cy.typeQuery(`select * from ${table}`) + cy.runLine() + + // Then + cy.getGridCellAt(0, 0).should("have.attr", "data-timestamp", "true") + cy.getGridCellAt(0, 1).should("not.have.attr", "data-timestamp") + }) + }) + + describe("cell formatting", () => { + it("renders null values as the literal 'null'", () => { + // Given / When + cy.typeQuery("select cast(null as long) n from long_sequence(1)") + cy.runLine() + + // Then + cy.getGridCellAt(0, 0).should("have.text", "null") + }) + }) + + describe("toolbar actions", () => { + it("copies the current page as a Markdown table", () => { + // Given + cy.typeQuery("select x from long_sequence(5)") + cy.runLine() + + // When + cy.gridToolbar("markdown").click() + + // Then + readClipboard().should("contain", "| x") + }) + + it("refresh re-runs the query and keeps the rows", () => { + // Given + cy.typeQuery("select x from long_sequence(5)") + cy.runLine() + cy.intercept("/exec*").as("refresh") + + // When + cy.gridToolbar("refresh").click() + + // Then + cy.wait("@refresh") + cy.getGridRows().should("have.length.greaterThan", 0) + }) }) }) diff --git a/e2e/tests/console/schema.spec.js b/e2e/tests/console/schema.spec.js index 14426a185..6d3289f7c 100644 --- a/e2e/tests/console/schema.spec.js +++ b/e2e/tests/console/schema.spec.js @@ -287,7 +287,7 @@ describe("keyboard navigation", () => { cy.getEditorContent().should("be.visible") cy.typeQuery("SELECT 123123;") cy.runLine() - cy.contains(".qg-c", "123123").click() + cy.contains("[data-hook='grid-cell']", "123123").click() cy.focused().should("contain", "123123") cy.expandMatViews() @@ -295,9 +295,12 @@ describe("keyboard navigation", () => { "contain", `Materialized views (${materializedViews.length})`, ) - cy.contains(".qg-c-active", "123123").should("not.exist") + cy.contains( + "[data-hook='grid-cell'][aria-selected='true']", + "123123", + ).should("not.exist") - cy.contains(".qg-c", "123123").click() + cy.contains("[data-hook='grid-cell']", "123123").click() cy.focused().should("contain", "123123") cy.getByDataHook("collapse-materialized-views").should( "not.have.class", diff --git a/e2e/tests/console/tableDetails.spec.js b/e2e/tests/console/tableDetails.spec.js index 0e705d7ec..0899e7269 100644 --- a/e2e/tests/console/tableDetails.spec.js +++ b/e2e/tests/console/tableDetails.spec.js @@ -859,7 +859,7 @@ describe("TableDetailsDrawer", () => { }) }) - describe("AI interactions disabled - schema access not granted", () => { + describe.only("AI interactions disabled - schema access not granted", () => { before(() => { cy.loadConsoleWithAuth() cy.createTable(TEST_TABLE) @@ -894,6 +894,7 @@ describe("TableDetailsDrawer", () => { ) cy.getByDataHook("table-details-tab-details").click() cy.getByDataHook("table-details-explain-ai").should("be.disabled") + cy.getByDataHook("table-details-copy-ddl").should("be.visible").click() cy.getByDataHook("table-details-explain-ai").realHover() cy.wait(200) cy.getByDataHook("tooltip").should( diff --git a/e2e/tests/enterprise/oidc.spec.js b/e2e/tests/enterprise/oidc.spec.js index a7c77022f..1c50a4798 100644 --- a/e2e/tests/enterprise/oidc.spec.js +++ b/e2e/tests/enterprise/oidc.spec.js @@ -221,7 +221,7 @@ describe("OIDC", () => { cy.logout() cy.loginWithUserAndPassword() cy.getEditor().should("be.visible") - cy.get(".qg-r").should("not.exist") + cy.get("[data-hook='grid-row']").should("not.exist") }) it("should preserve query and executeQuery params across OIDC redirect and show share-link confirmation dialog", () => { diff --git a/package.json b/package.json index 9cc6f242a..15d6a5664 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "@styled-icons/remix-editor": "^10.46.0", "@styled-icons/remix-fill": "10.46.0", "@styled-icons/remix-line": "10.46.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.2", "allotment": "^1.19.3", "bowser": "^2.14.1", "compare-versions": "^5.0.1", diff --git a/scripts/run_browser_tests.sh b/scripts/run_browser_tests.sh index bebf60f83..33502b105 100755 --- a/scripts/run_browser_tests.sh +++ b/scripts/run_browser_tests.sh @@ -14,6 +14,19 @@ UI_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" # Change to UI directory cd "$UI_DIR" +# QuestDB's maven enforcer needs the java25+ profile (JDK 25+) to activate; pin JDK 25 to match CI. +if [ -z "$JAVA_HOME" ] || ! "$JAVA_HOME/bin/java" -version 2>&1 | grep -q '"25'; then + if [ -x /usr/libexec/java_home ]; then + JAVA_HOME=$(/usr/libexec/java_home -v 25 2>/dev/null) + fi +fi +if [ -z "$JAVA_HOME" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then + echo "Error: could not locate JDK 25. Install one (e.g. 'brew install openjdk@25') or set JAVA_HOME." >&2 + exit 1 +fi +export JAVA_HOME +export PATH="$JAVA_HOME/bin:$PATH" + # Cleanup rm -rf tmp/dbroot rm -rf tmp/questdb-* @@ -23,7 +36,13 @@ if [[ $1 = "-skipQuestDBBuild" ]] then echo "Skipping QuestDB build" else - mvn clean package -e -f e2e/questdb/pom.xml -DskipTests -P build-binaries 2>&1 + CLIENT_VERSION=$(grep -m1 -oE '[^<]+' e2e/questdb/core/pom.xml | sed 's/.*>//') + PROFILES=build-binaries + if [[ "$CLIENT_VERSION" == *-SNAPSHOT ]]; then + git submodule update --init --recursive e2e/questdb + PROFILES=$PROFILES,local-client + fi + mvn clean package -e -f e2e/questdb/pom.xml -DskipTests -P "$PROFILES" 2>&1 fi # Unpack server diff --git a/scripts/run_ent_browser_tests.sh b/scripts/run_ent_browser_tests.sh index 023583295..6e5750cda 100755 --- a/scripts/run_ent_browser_tests.sh +++ b/scripts/run_ent_browser_tests.sh @@ -88,7 +88,13 @@ CORE_MAIN_DIR=tmp/questdb-enterprise/questdb/core/target/classes if [ "$CACHED" -eq 1 ] && [ -f "$ENT_MAIN_CLASS" ] && [ -d "$CORE_MAIN_DIR" ]; then echo "Reusing existing maven build output" else - mvn clean package -e -f tmp/questdb-enterprise/pom.xml -DskipTests -P build-ent-binaries 2>&1 + CLIENT_VERSION=$(grep -m1 -oE '[^<]+' tmp/questdb-enterprise/questdb/core/pom.xml | sed 's/.*>//') + PROFILES=build-ent-binaries + if [[ "$CLIENT_VERSION" == *-SNAPSHOT ]]; then + git -C tmp/questdb-enterprise submodule update --init --recursive + PROFILES=$PROFILES,local-client + fi + mvn clean package -e -f tmp/questdb-enterprise/pom.xml -DskipTests -P "$PROFILES" 2>&1 fi # Create dbroot diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 7ca76381e..745451c51 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -43,6 +43,7 @@ type BaseButtonProps = { fontSize?: FontSize onClick?: (event: MouseEvent) => void onDoubleClick?: (event: MouseEvent) => void + onMouseDown?: (event: MouseEvent) => void size?: Size fullWidth?: boolean type?: Type diff --git a/src/components/ResultGrid/ResultGrid.tsx b/src/components/ResultGrid/ResultGrid.tsx new file mode 100644 index 000000000..03d2e46e8 --- /dev/null +++ b/src/components/ResultGrid/ResultGrid.tsx @@ -0,0 +1,900 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { + useReactTable, + getCoreRowModel, + flexRender, + type ColumnDef, + type ColumnPinningState, +} from "@tanstack/react-table" + +import type { ColumnDefinition } from "../../utils/questdb/types" +import { unescapeHtml } from "../../utils/escapeHtml" +import type { ResultGridDataSource } from "./types" +import { + clampColumnWidths, + sampleColumnWidths, + isLeftAligned, + formatCellValue, + formatColumnType, +} from "./inlineGridUtils" +import { useGridKeyboardNav } from "./useGridKeyboardNav" +import { + Cell, + CellText, + ColResizer, + DatasetRow, + GridContainer, + HeaderCell, + HeaderName, + HeaderNameRow, + HeaderRow, + HeaderType, + HEADER_HEIGHT, + FreezeHandle, + FrozenShadow, + ResizeGhost, + ResizerOverlay, + Row, + ROW_HEIGHT, + ScrollContainer, + StyledCopyButton, +} from "./styles" +import { + MAX_VIRTUAL_ROWS, + toAbsoluteIndex, + toVisibleAbsoluteRange, +} from "./virtualRowMapping" +import { useContainerWidth } from "./useContainerWidth" +import { useScrollShadows } from "./useScrollShadows" + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + col?: ColumnDefinition + } +} + +const WIDTH_SAMPLE_ROWS = 1000 +const KEYBOARD_RESIZE_COMMIT_DEBOUNCE_MS = 200 +const COLUMN_ID_PREFIX = "col_" +const columnId = (dataIndex: number) => `${COLUMN_ID_PREFIX}${dataIndex}` + +type GridCellProps = { + rowIndex: number + colIndex: number + rawValue: boolean | string | number | null + loaded: boolean + col: ColumnDefinition | undefined + colWidth: number + left: number + width: number + isActive: boolean + isPulsing: boolean + isDesignatedTimestamp: boolean + frozen?: boolean + rowActive: boolean + onCellClick: (row: number, col: number) => void +} + +const GridCell = React.memo(function GridCell({ + rowIndex, + colIndex, + rawValue, + loaded, + col, + colWidth, + left, + width, + isActive, + isPulsing, + isDesignatedTimestamp, + frozen, + rowActive, + onCellClick, +}: GridCellProps) { + const colType = col?.type ?? "" + const align = isLeftAligned(colType) ? "left" : "right" + const displayValue = loaded + ? unescapeHtml(formatCellValue(rawValue, col, colWidth)) + : "" + return ( + onCellClick(rowIndex, colIndex)} + role="gridcell" + aria-colindex={colIndex + 1} + aria-selected={isActive} + > + {displayValue} + + ) +}) + +type Props = { + dataSource: ResultGridDataSource + runToken?: number // changes per run to reset focus/selection on the grid + isFocused?: boolean + initialColumnSizing?: Record + onColumnSizingCommit: (sizing: Record) => void + initialColumnOrder?: string[] + onColumnOrderCommit?: (order: string[]) => void + initialPinnedColumns?: string[] + onPinnedColumnsCommit?: (pinnedLeft: string[]) => void + onYieldFocus?: () => void + onResetLayout?: () => void + onSelectionChange?: (hasSelection: boolean) => void + onCellCopy?: () => void + onColumnCopy?: () => void +} + +export type ResultGridHandle = { + resetLayout: () => void + shuffleFocusedColumnToFront: () => void + toggleFreezeLeft: () => void +} + +const EMPTY_TABLE_DATA: DatasetRow[] = [] + +export const ResultGrid = forwardRef( + ( + { + dataSource, + runToken, + isFocused = true, + initialColumnSizing, + onColumnSizingCommit, + initialColumnOrder, + onColumnOrderCommit, + initialPinnedColumns, + onPinnedColumnsCommit, + onYieldFocus, + onResetLayout, + onSelectionChange, + onCellCopy, + onColumnCopy, + }, + ref, + ) => { + const { + columns, + rowCount, + designatedTimestamp, + getRow, + sampleRows, + onVisibleRowsChange, + } = dataSource + + const gridRef = useRef(null) + const scrollRef = useRef(null) + const [freezeDragX, setFreezeDragX] = useState(null) + const freezeTargetRef = useRef(0) + + const containerWidth = useContainerWidth(gridRef) + const { scrolledDown, shadowLeft, handleScroll } = + useScrollShadows(scrollRef) + + const virtualRowCount = Math.min(rowCount, MAX_VIRTUAL_ROWS) + + // Sampling text lengths over 1000 rows is the expensive part, so it runs + // once per result; the per-resize work is only the container clamp. + const sampledWidths = useMemo( + () => sampleColumnWidths(columns, sampleRows.slice(0, WIDTH_SAMPLE_ROWS)), + [columns, sampleRows], + ) + + const columnDefs = useMemo[]>(() => { + const widths = clampColumnWidths(sampledWidths, containerWidth) + return columns.map((col, i) => ({ + id: columnId(i), + accessorFn: (row: DatasetRow) => row[i], + header: col.name, + size: widths[i], + minSize: 60, + meta: { col }, + })) + }, [columns, sampledWidths, containerWidth]) + + const [columnOrder, setColumnOrder] = useState([]) + const [columnPinning, setColumnPinning] = useState({ + left: [], + right: [], + }) + + const table = useReactTable({ + // Rows come from the windowed data source, not the table — an empty + // dataset keeps the table from holding every row. + data: EMPTY_TABLE_DATA, + columns: columnDefs, + columnResizeMode: "onEnd", + state: { columnOrder, columnPinning }, + onColumnOrderChange: setColumnOrder, + onColumnPinningChange: setColumnPinning, + getCoreRowModel: getCoreRowModel(), + }) + + // Restore before paint; an empty value clears any prior overrides. A user + // resize updates TanStack's own columnSizing, not these props, so it stays. + useLayoutEffect(() => { + table.setColumnSizing(initialColumnSizing ?? {}) + }, [initialColumnSizing]) + + useLayoutEffect(() => { + setColumnOrder(initialColumnOrder ?? []) + }, [initialColumnOrder]) + + useLayoutEffect(() => { + setColumnPinning({ left: initialPinnedColumns ?? [], right: [] }) + }, [initialPinnedColumns]) + + const leftHeaders = table.getLeftHeaderGroups()[0]?.headers ?? [] + const centerHeaders = table.getCenterHeaderGroups()[0]?.headers ?? [] + const headers = [...leftHeaders, ...centerHeaders] + const frozenCount = leftHeaders.length + const frozenWidth = table.getLeftTotalSize() + + // Must follow the visual layout (pinned columns first), the same order as + // `headers`. getVisibleLeafColumns() ignores pinning, so deriving the data + // index from it would mismatch a frozen column whose neighbour was moved. + const visualLeafIds = useMemo( + () => headers.map((header) => header.column.id), + [columnOrder, columnPinning, columnDefs], + ) + const dataIndexAt = useCallback( + (visualCol: number): number => { + const id = visualLeafIds[visualCol] + return id ? parseInt(id.slice(COLUMN_ID_PREFIX.length), 10) : visualCol + }, + [visualLeafIds], + ) + + // undefined means the row's page hasn't loaded yet, unlike a SQL null. + const getData = useCallback( + (row: number, col: number) => + getRow(toAbsoluteIndex(row, rowCount))?.[dataIndexAt(col)], + [getRow, rowCount, dataIndexAt], + ) + + const getColumn = useCallback( + (col: number) => columns[dataIndexAt(col)], + [columns, dataIndexAt], + ) + + const moveColumnToFront = useCallback( + (visualCol: number): number | null => { + const id = visualLeafIds[visualCol] + if (!id) return null + const frontIndex = visualCol < frozenCount ? 0 : frozenCount + if (visualCol === frontIndex) return frontIndex + // A frozen column reorders within the frozen band — its columnOrder is + // ignored while pinned, so reorder the pin list instead, landing it at + // visual index 0. This matches the legacy grid. + if (visualCol < frozenCount) { + const left = columnPinning.left ?? [] + const nextLeft = [id, ...left.filter((other) => other !== id)] + setColumnPinning({ left: nextLeft, right: [] }) + onPinnedColumnsCommit?.(nextLeft) + return frontIndex + } + const ids = columnOrder.length + ? columnOrder + : columnDefs.map((d) => d.id as string) + const next = [id, ...ids.filter((other) => other !== id)] + setColumnOrder(next) + onColumnOrderCommit?.(next) + return frontIndex + }, + [ + visualLeafIds, + frozenCount, + columnPinning, + columnOrder, + columnDefs, + onColumnOrderCommit, + onPinnedColumnsCommit, + ], + ) + + const toggleFreeze = useCallback(() => { + let next: ColumnPinningState + if ((columnPinning.left ?? []).length > 0) { + next = { left: [], right: [] } + } else { + const firstId = table.getCenterLeafColumns()[0]?.id + next = firstId ? { left: [firstId], right: [] } : columnPinning + } + setColumnPinning(next) + onPinnedColumnsCommit?.(next.left ?? []) + }, [columnPinning, table, onPinnedColumnsCommit]) + + const applyFreeze = useCallback( + (count: number) => { + const pinned = visualLeafIds.slice(0, count) + setColumnPinning({ left: pinned, right: [] }) + onPinnedColumnsCommit?.(pinned) + }, + [visualLeafIds, onPinnedColumnsCommit], + ) + + const onFreezeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const gridEl = gridRef.current + const scrollEl = scrollRef.current + if (!gridEl || !scrollEl) return + const gridLeft = gridEl.getBoundingClientRect().left + const viewportWidth = scrollEl.clientWidth + const scrollLeft = scrollEl.scrollLeft + + // Hold the resize cursor for the whole drag — it inherits to the cells, + // which would otherwise reset it to default as the pointer leaves the + // handle. + document.body.style.cursor = "col-resize" + + // Candidate k = "freeze k columns"; its boundary is fixed in the frozen + // region but scrolls with content past it. Keep one column scrollable. + let cumulativeWidth = 0 + const candidates: { k: number; x: number }[] = [{ k: 0, x: 0 }] + for (let i = 0; i < headers.length - 1; i++) { + cumulativeWidth += headers[i].getSize() + const k = i + 1 + const x = + k <= frozenCount ? cumulativeWidth : cumulativeWidth - scrollLeft + if (x >= 0 && x <= viewportWidth) candidates.push({ k, x }) + } + + freezeTargetRef.current = frozenCount + + const onMove = (ev: MouseEvent) => { + const cursorX = ev.clientX - gridLeft + let best = candidates[0] + for (const c of candidates) { + if (Math.abs(c.x - cursorX) < Math.abs(best.x - cursorX)) best = c + } + setFreezeDragX(best.x) + freezeTargetRef.current = best.k + } + const onUp = () => { + window.removeEventListener("mousemove", onMove) + window.removeEventListener("mouseup", onUp) + document.body.style.cursor = "" + setFreezeDragX(null) + applyFreeze(freezeTargetRef.current) + } + window.addEventListener("mousemove", onMove) + window.addEventListener("mouseup", onUp) + }, + [headers, frozenCount, applyFreeze], + ) + + const resetLayout = useCallback(() => { + table.setColumnSizing({}) + setColumnOrder([]) + setColumnPinning({ left: [], right: [] }) + onPinnedColumnsCommit?.([]) + if (scrollRef.current) scrollRef.current.scrollLeft = 0 + onResetLayout?.() + }, [table, onResetLayout, onPinnedColumnsCommit]) + + const scrollContextRef = useRef<{ + scrollElement: HTMLElement + rowHeight: number + headerHeight: number + frozenWidth: number + frozenColCount: number + getColumnOffset: (col: number) => number + getColumnWidth: (col: number) => number + } | null>(null) + + useEffect(() => { + if (scrollRef.current) { + scrollContextRef.current = { + scrollElement: scrollRef.current, + rowHeight: ROW_HEIGHT, + headerHeight: HEADER_HEIGHT, + frozenWidth, + frozenColCount: frozenCount, + getColumnOffset: (col: number) => { + let offset = 0 + for (let i = 0; i < col; i++) { + offset += headers[i]?.getSize() ?? 0 + } + return offset + }, + getColumnWidth: (col: number) => headers[col]?.getSize() ?? 0, + } + } + }, [headers, frozenWidth, frozenCount]) + + const { + focusedCell, + setFocusedCell, + copyPulse, + onCellClick, + onKeyDown, + onBlur, + } = useGridKeyboardNav( + virtualRowCount, + columns.length, + getData, + getColumn, + scrollContextRef, + onCellCopy, + ) + + const hasSelection = focusedCell != null + useEffect(() => { + onSelectionChange?.(hasSelection) + }, [hasSelection, onSelectionChange]) + + const shuffleFocusedColumnToFront = useCallback(() => { + if (!focusedCell) return + const targetCol = moveColumnToFront(focusedCell.col) + if (targetCol === null) return + setFocusedCell({ row: focusedCell.row, col: targetCol }) + // The column lands at the left edge of the scrollable area, so scroll + // there to keep it in view. A frozen target is sticky and already shown. + if (targetCol >= frozenCount && scrollRef.current) { + scrollRef.current.scrollLeft = 0 + } + }, [focusedCell, moveColumnToFront, setFocusedCell, frozenCount]) + + const handleGridKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "F2") { + e.preventDefault() + setFocusedCell(null) + gridRef.current?.blur() + onYieldFocus?.() + return + } + // stopPropagation so "/" doesn't reach DocSearch's global shortcut. + if (e.key === "/" && !e.metaKey && !e.ctrlKey && focusedCell) { + e.preventDefault() + e.stopPropagation() + shuffleFocusedColumnToFront() + return + } + // preventDefault stops the browser's bookmark shortcut. + if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) { + e.preventDefault() + e.stopPropagation() + resetLayout() + return + } + onKeyDown(e) + }, + [ + onKeyDown, + focusedCell, + shuffleFocusedColumnToFront, + onYieldFocus, + setFocusedCell, + resetLayout, + ], + ) + + useImperativeHandle( + ref, + () => ({ + resetLayout, + toggleFreezeLeft: toggleFreeze, + shuffleFocusedColumnToFront, + }), + [resetLayout, toggleFreeze, shuffleFocusedColumnToFront], + ) + + const prevRunTokenRef = useRef(runToken) + + const isCellFocused = (row: number, col: number) => + focusedCell?.row === row && focusedCell?.col === col + + const isCellPulsing = (row: number, col: number) => + copyPulse?.row === row && copyPulse?.col === col + + const rowVirtualizer = useVirtualizer({ + count: virtualRowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 3, + }) + + const columnSizing = table.getState().columnSizing + const isResizingColumn = + !!table.getState().columnSizingInfo.isResizingColumn + + const wasResizingRef = useRef(isResizingColumn) + useEffect(() => { + if (wasResizingRef.current && !isResizingColumn) { + onColumnSizingCommit(columnSizing) + } + wasResizingRef.current = isResizingColumn + }, [isResizingColumn, columnSizing, onColumnSizingCommit]) + + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: headers.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => headers[index]?.getSize() ?? 100, + overscan: 2, + }) + + // Re-measure so a reorder/resize/new-data doesn't leave the virtualizer with + // stale per-index widths until the next scroll. + useEffect(() => { + columnVirtualizer.measure() + }, [ + columnSizing, + columnDefs, + columnOrder, + columnPinning, + columnVirtualizer, + ]) + + useEffect(() => { + if (prevRunTokenRef.current === runToken) return + prevRunTokenRef.current = runToken + setFocusedCell(null) + gridRef.current?.blur() + if (scrollRef.current) { + scrollRef.current.scrollTop = 0 + scrollRef.current.scrollLeft = 0 + } + }, [runToken]) + + const totalWidth = columnVirtualizer.getTotalSize() + const totalHeight = rowVirtualizer.getTotalSize() + const virtualRows = rowVirtualizer.getVirtualItems() + const virtualColumns = columnVirtualizer.getVirtualItems() + + const firstVirtual = virtualRows[0]?.index ?? 0 + const lastVirtual = virtualRows[virtualRows.length - 1]?.index ?? 0 + const prevFirstAbsRef = useRef(0) + useEffect(() => { + if (!onVisibleRowsChange || virtualRowCount === 0) return + const { firstIndex, lastIndex } = toVisibleAbsoluteRange( + firstVirtual, + lastVirtual, + rowCount, + ) + const direction = firstIndex >= prevFirstAbsRef.current ? 1 : -1 + prevFirstAbsRef.current = firstIndex + onVisibleRowsChange({ firstIndex, lastIndex, direction }) + }, [ + firstVirtual, + lastVirtual, + rowCount, + virtualRowCount, + onVisibleRowsChange, + ]) + + const sizingCommitTimerRef = useRef | null>( + null, + ) + const commitSizingDebounced = useCallback( + (sizing: Record) => { + if (sizingCommitTimerRef.current) { + clearTimeout(sizingCommitTimerRef.current) + } + sizingCommitTimerRef.current = setTimeout(() => { + onColumnSizingCommit(sizing) + }, KEYBOARD_RESIZE_COMMIT_DEBOUNCE_MS) + }, + [onColumnSizingCommit], + ) + useEffect( + () => () => { + if (sizingCommitTimerRef.current) { + clearTimeout(sizingCommitTimerRef.current) + } + }, + [], + ) + + // Shared by the in-header (center columns) and overlay (frozen columns) + // resizers. style positions the overlay ones; header ones use the default. + const renderResizer = useCallback( + (header: (typeof headers)[number], style?: React.CSSProperties) => ( + { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return + e.preventDefault() + const step = e.shiftKey ? 40 : 10 + const delta = e.key === "ArrowRight" ? step : -step + const next = Math.max(60, header.getSize() + delta) + const nextSizing = { + ...table.getState().columnSizing, + [header.column.id]: next, + } + table.setColumnSizing(nextSizing) + commitSizingDebounced(nextSizing) + }} + role="separator" + aria-orientation="vertical" + aria-label={`Resize column ${header.column.columnDef.meta?.col?.name ?? ""}`} + tabIndex={0} + /> + ), + [table, commitSizingDebounced], + ) + + const headerSignature = virtualColumns + .map((c) => `${c.index}:${c.start}:${c.size}`) + .join("|") + + const headerRow = useMemo(() => { + const renderHeaderCell = ( + header: (typeof headers)[number], + visualIndex: number, + pos: { frozen: boolean; left: number; width: number }, + ) => { + const col = header.column.columnDef.meta?.col + const colType = col?.type ?? "" + const align = isLeftAligned(colType) ? "left" : "right" + return ( + + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + + {col ? formatColumnType(col) : colType} + {!pos.frozen && renderResizer(header)} + + ) + } + return ( + + {headers.slice(0, frozenCount).map((header, i) => + renderHeaderCell(header, i, { + frozen: true, + left: header.column.getStart("left"), + width: header.getSize(), + }), + )} + {virtualColumns.map((virtualCol) => { + if (virtualCol.index < frozenCount) return null + const header = headers[virtualCol.index] + if (!header) return null + return renderHeaderCell(header, virtualCol.index, { + frozen: false, + left: virtualCol.start, + width: virtualCol.size, + }) + })} + + ) + }, [ + headerSignature, + scrolledDown, + totalWidth, + columnSizing, + frozenCount, + // headerSignature omits order, so a same-width reorder needs this to + // avoid stale column names. + columnOrder, + columnPinning, + onColumnCopy, + renderResizer, + ]) + + const sizingInfo = table.getState().columnSizingInfo + let resizeGhostLeft: number | null = null + if (sizingInfo.isResizingColumn) { + const idx = headers.findIndex( + (h) => h.column.id === sizingInfo.isResizingColumn, + ) + if (idx >= 0) { + let left = 0 + for (let i = 0; i < idx; i++) left += headers[i].getSize() + const futureWidth = Math.max( + 60, + headers[idx].getSize() + (sizingInfo.deltaOffset ?? 0), + ) + const scrollLeft = + idx < frozenCount ? 0 : (scrollRef.current?.scrollLeft ?? 0) + resizeGhostLeft = left + futureWidth - scrollLeft + } + } + + return ( + + + {headerRow} + +
+ {virtualRows.map((virtualRow) => { + const virtualIndex = virtualRow.index + const absoluteIndex = toAbsoluteIndex(virtualIndex, rowCount) + const rowData = getRow(absoluteIndex) + const renderBodyCell = ( + header: (typeof headers)[number], + colIdx: number, + pos: { frozen: boolean; left: number; width: number }, + ) => { + const dataIndex = dataIndexAt(colIdx) + return ( + + ) + } + return ( + + {headers.slice(0, frozenCount).map((header, i) => + renderBodyCell(header, i, { + frozen: true, + left: header.column.getStart("left"), + width: header.getSize(), + }), + )} + {virtualColumns.map((virtualCol) => { + if (virtualCol.index < frozenCount) return null + const header = headers[virtualCol.index] + if (!header) return null + return renderBodyCell(header, virtualCol.index, { + frozen: false, + left: virtualCol.start, + width: virtualCol.size, + }) + })} + + ) + })} +
+
+ {frozenCount > 0 && shadowLeft && ( + + )} + {(frozenCount > 0 || headers.length > 1) && ( + 0 + ? "Drag to freeze more or fewer columns" + : "Drag to freeze columns" + } + /> + )} + {frozenCount > 0 && ( + + {headers.slice(0, frozenCount).map((header) => + renderResizer(header, { + left: header.column.getStart("left") + header.getSize() - 10, + right: "auto", + }), + )} + + )} + {freezeDragX !== null && } + {resizeGhostLeft !== null && ( + + )} +
+ ) + }, +) + +ResultGrid.displayName = "ResultGrid" diff --git a/src/components/ResultGrid/dimensions.ts b/src/components/ResultGrid/dimensions.ts new file mode 100644 index 000000000..d5a632ac3 --- /dev/null +++ b/src/components/ResultGrid/dimensions.ts @@ -0,0 +1,4 @@ +// Dependency-free so pure modules (e.g. virtualRowMapping) can import it +// without pulling React into a unit-test environment. +export const ROW_HEIGHT = 30 +export const HEADER_HEIGHT = 44 diff --git a/src/components/ResultGrid/index.ts b/src/components/ResultGrid/index.ts new file mode 100644 index 000000000..9474862df --- /dev/null +++ b/src/components/ResultGrid/index.ts @@ -0,0 +1,21 @@ +export { ResultGrid } from "./ResultGrid" +export type { ResultGridHandle } from "./ResultGrid" + +export type { + DqlQueryResult, + ResultGridDataSource, + ResultGridRow, +} from "./types" +export { inMemoryDataSource } from "./types" + +export { + clampColumnWidths, + sampleColumnWidths, + formatCellValue, + formatCellValueForCopy, + formatColumnType, + isLeftAligned, +} from "./inlineGridUtils" +export { buildResultPageMarkdown } from "./resultPageMarkdown" +export { HEADER_HEIGHT, ROW_HEIGHT } from "./dimensions" +export { toAbsoluteIndex } from "./virtualRowMapping" diff --git a/src/components/ResultGrid/inlineGridUtils.test.ts b/src/components/ResultGrid/inlineGridUtils.test.ts new file mode 100644 index 000000000..a4c064891 --- /dev/null +++ b/src/components/ResultGrid/inlineGridUtils.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "vitest" +import { + clampColumnWidths, + sampleColumnWidths, + formatCellValue, + formatCellValueForCopy, + formatColumnType, + isLeftAligned, +} from "./inlineGridUtils" +import type { ColumnDefinition } from "../../utils/questdb/types" + +const col = ( + name: string, + type: string, + extra: Partial = {}, +): ColumnDefinition => ({ name, type, ...extra }) + +describe("isLeftAligned", () => { + it("returns true for string-like types", () => { + expect(isLeftAligned("STRING")).toBe(true) + expect(isLeftAligned("SYMBOL")).toBe(true) + expect(isLeftAligned("VARCHAR")).toBe(true) + expect(isLeftAligned("ARRAY")).toBe(true) + }) + it("returns true regardless of case", () => { + expect(isLeftAligned("string")).toBe(true) + expect(isLeftAligned("Symbol")).toBe(true) + }) + it("returns false for numeric/timestamp/boolean types", () => { + expect(isLeftAligned("INT")).toBe(false) + expect(isLeftAligned("DOUBLE")).toBe(false) + expect(isLeftAligned("TIMESTAMP")).toBe(false) + expect(isLeftAligned("BOOLEAN")).toBe(false) + }) +}) + +describe("formatColumnType", () => { + it("lowercases non-array types", () => { + expect(formatColumnType(col("x", "INT"))).toBe("int") + expect(formatColumnType(col("x", "TIMESTAMP"))).toBe("timestamp") + }) + + it("renders 1-D arrays as elemType[]", () => { + expect( + formatColumnType(col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" })), + ).toBe("double[]") + }) + + it("renders 2-D arrays as elemType[][]", () => { + expect( + formatColumnType(col("x", "ARRAY", { dim: 2, elemType: "DOUBLE" })), + ).toBe("double[][]") + }) + + it("renders dim>2 arrays with numeric dim form", () => { + expect( + formatColumnType(col("x", "ARRAY", { dim: 3, elemType: "double" })), + ).toBe("ARRAY(DOUBLE,3)") + }) + + it("falls back to 'unknown' when elemType is missing", () => { + expect(formatColumnType(col("x", "ARRAY", { dim: 1 }))).toBe("unknown[]") + }) +}) + +describe("formatCellValue", () => { + it("returns 'null' for null", () => { + expect(formatCellValue(null)).toBe("null") + }) + + it("returns 'true'/'false' for booleans", () => { + expect(formatCellValue(true)).toBe("true") + expect(formatCellValue(false)).toBe("false") + }) + + it("returns string of number by default", () => { + expect(formatCellValue(42)).toBe("42") + expect(formatCellValue(3.14)).toBe("3.14") + }) + + it("adds .0 for integer-valued FLOAT/DOUBLE", () => { + expect(formatCellValue(5, col("x", "FLOAT"))).toBe("5.0") + expect(formatCellValue(5, col("x", "DOUBLE"))).toBe("5.0") + }) + + it("does not alter non-integer float values", () => { + expect(formatCellValue(5.2, col("x", "FLOAT"))).toBe("5.2") + }) + + it("does not apply float suffix to non-float types", () => { + expect(formatCellValue(5, col("x", "INT"))).toBe("5") + }) + + it("formats 1-D array values (.0 on every integer, matching grid.js)", () => { + expect( + formatCellValue( + [1, 2, 3] as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + ), + ).toBe("ARRAY[1.0,2.0,3.0]") + }) + + it("adds .0 to integer elements of float arrays", () => { + expect( + formatCellValue( + [1, 2] as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" }), + ), + ).toBe("ARRAY[1.0,2.0]") + }) + + it("renders null arrays as 'null'", () => { + expect( + formatCellValue(null, col("x", "ARRAY", { dim: 1, elemType: "INT" })), + ).toBe("null") + }) + + it("truncates array content when columnWidth is tight", () => { + // columnWidth=200 → maxArrayTextLength = ceil(200/8.3) = 25, minus 7 + // overhead = 18 chars of content, which is less than a 100-element + // integer array stringified. + const longArray = Array.from({ length: 100 }, (_, i) => i) + const out = formatCellValue( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + 200, + ) + expect(out.startsWith("ARRAY[")).toBe(true) + expect(out.endsWith("]")).toBe(true) + expect(out).toContain("...") + }) + + it("leaves array untruncated when columnWidth leaves ≤3 chars of content", () => { + // columnWidth=80 → maxContentLength drops to 3; the truncation branch is + // skipped (guard: maxContentLength > 3) and the full array is returned. + const longArray = Array.from({ length: 100 }, (_, i) => i) + const out = formatCellValue( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + 80, + ) + expect(out).not.toContain("...") + expect(out).toContain("99") + }) + + it("does not truncate when columnWidth is absent", () => { + const longArray = Array.from({ length: 50 }, (_, i) => i) + const out = formatCellValue( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + ) + expect(out).not.toContain("...") + }) +}) + +describe("formatCellValueForCopy", () => { + it("returns 'null' for null", () => { + expect(formatCellValueForCopy(null)).toBe("null") + }) + + it("returns the same as formatCellValue for primitives", () => { + expect(formatCellValueForCopy(true)).toBe("true") + expect(formatCellValueForCopy(42)).toBe("42") + expect(formatCellValueForCopy("hi")).toBe("hi") + }) + + it("returns the full array without truncation", () => { + const longArray = Array.from({ length: 100 }, (_, i) => i) + const out = formatCellValueForCopy( + longArray as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "INT" }), + ) + expect(out.startsWith("ARRAY[")).toBe(true) + expect(out.endsWith("]")).toBe(true) + expect(out).not.toContain("...") + expect(out).toContain("0.0,1.0,2.0") + expect(out).toContain("99.0") + }) + + it("preserves float suffix in copy form", () => { + const out = formatCellValueForCopy( + [1, 2] as unknown as number, + col("x", "ARRAY", { dim: 1, elemType: "DOUBLE" }), + ) + expect(out).toBe("ARRAY[1.0,2.0]") + }) + + it("unescapes HTML entities so copy matches the displayed text", () => { + // Given a string value carrying HTML entities, as the grid displays it + // unescaped + // When formatting it for copy + const out = formatCellValueForCopy("a&b<c>d", col("x", "VARCHAR")) + + // Then the copied text is unescaped, matching the cell + expect(out).toBe("a&bd") + }) +}) + +describe("sampleColumnWidths", () => { + it("returns one entry per column", () => { + const columns = [col("a", "INT"), col("b", "STRING"), col("c", "DOUBLE")] + const widths = sampleColumnWidths(columns, []) + expect(widths).toHaveLength(3) + }) + + it("respects the MIN_COLUMN_WIDTH floor", () => { + const widths = sampleColumnWidths([col("a", "INT")], []) + expect(widths[0]).toBeGreaterThanOrEqual(60) + }) + + it("widens for longer data values", () => { + const widthShort = sampleColumnWidths([col("a", "STRING")], [["hi"]])[0] + const widthLong = sampleColumnWidths( + [col("a", "STRING")], + [["hello world this is longer"]], + )[0] + expect(widthLong).toBeGreaterThan(widthShort) + }) + + it("includes header + type length when sizing", () => { + const narrow = sampleColumnWidths([col("x", "INT")], [[1]])[0] + const wide = sampleColumnWidths( + [col("extremely_long_column_name", "INT")], + [[1]], + )[0] + expect(wide).toBeGreaterThan(narrow) + }) + + it("handles empty dataset", () => { + const widths = sampleColumnWidths([col("a", "INT")], []) + expect(widths).toHaveLength(1) + expect(widths[0]).toBeGreaterThanOrEqual(60) + }) +}) + +describe("clampColumnWidths", () => { + it("caps each width at containerWidth * 0.8", () => { + expect(clampColumnWidths([1000, 100], 1000)).toEqual([800, 100]) + }) + + it("keeps widths below the cap untouched", () => { + expect(clampColumnWidths([200, 300], 1000)).toEqual([200, 300]) + }) + + it("caps a long sampled value at the container limit", () => { + // Given a column whose sampled value is far wider than the container + const sampled = sampleColumnWidths( + [col("long", "STRING")], + [["x".repeat(1000)]], + ) + + // When the container clamp is applied + const widths = clampColumnWidths(sampled, 1000) + + // Then the column never exceeds 80% of the container + expect(widths[0]).toBeLessThanOrEqual(800) + }) +}) diff --git a/src/components/ResultGrid/inlineGridUtils.ts b/src/components/ResultGrid/inlineGridUtils.ts new file mode 100644 index 000000000..cada33d3a --- /dev/null +++ b/src/components/ResultGrid/inlineGridUtils.ts @@ -0,0 +1,176 @@ +import type { ColumnDefinition } from "../../utils/questdb/types" +import { unescapeHtml } from "../../utils/escapeHtml" + +const CELL_WIDTH_MULTIPLIER = 9.6 +const ARRAY_CELL_WIDTH_MULTIPLIER = 8.3 +const MIN_COLUMN_WIDTH = 60 +const MAX_WIDTH_RATIO = 0.8 + +// Non-text horizontal space a header cell reserves but a data cell doesn't: cell +// padding + flex gap + the always-rendered (visibility:hidden) sm copy button. +const HEADER_PADDING_PX = 32 +const HEADER_GAP_PX = 6 +const HEADER_COPY_BUTTON_PX = 32 +const HEADER_CHROME_PX = + HEADER_PADDING_PX + HEADER_GAP_PX + HEADER_COPY_BUTTON_PX + +const LEFT_ALIGNED_TYPES = new Set(["STRING", "SYMBOL", "VARCHAR", "ARRAY"]) + +const FLOAT_TYPES = new Set(["FLOAT", "DOUBLE"]) + +const isArrayColumn = (col: ColumnDefinition): boolean => col.type === "ARRAY" + +const getCellWidth = (textLength: number, isArray = false): number => { + const multiplier = isArray + ? ARRAY_CELL_WIDTH_MULTIPLIER + : CELL_WIDTH_MULTIPLIER + return Math.max(MIN_COLUMN_WIDTH, Math.ceil(textLength * multiplier)) +} + +const getArrayString = (value: unknown): string => { + const json = JSON.stringify(value, (_, val: unknown) => { + if (typeof val === "number" && Number.isInteger(val)) { + return val.toString() + ".0" + } + return val + }) + return json.replace(/"/g, "") +} + +const wrapArray = (content: string, dim: number): string => + `ARRAY${"[".repeat(dim)}${content}${"]".repeat(dim)}` + +const arrayContent = (value: unknown, dim: number): string => + getArrayString(value).slice(dim, -dim) + +const formatArrayFull = (value: unknown, col: ColumnDefinition): string => { + if (value === null) return "null" + const dim = col.dim ?? 1 + return wrapArray(arrayContent(value, dim), dim) +} + +const formatArrayValue = ( + value: unknown, + col: ColumnDefinition, + columnWidth?: number, +): string => { + if (value === null) return "null" + const dim = col.dim ?? 1 + const content = arrayContent(value, dim) + const full = wrapArray(content, dim) + + if (!columnWidth) return full + + const maxArrayTextLength = Math.ceil( + columnWidth / ARRAY_CELL_WIDTH_MULTIPLIER, + ) + const maxContentLength = maxArrayTextLength - (dim * 2 + "ARRAY".length) + + if (content.length > maxContentLength && maxContentLength > 3) { + return wrapArray(`${content.slice(0, maxContentLength)}...`, dim) + } + + return full +} + +// Sampling is container-independent so it runs once per result; the +// container-driven cap is applied separately by clampColumnWidths. The +// constant ceiling keeps the sampling loop bounded for very long values. +const MAX_SAMPLED_WIDTH_PX = 4000 + +export const sampleColumnWidths = ( + columns: ColumnDefinition[], + dataset: (boolean | string | number | null)[][], +): number[] => { + const maxTextLenRegular = Math.ceil( + MAX_SAMPLED_WIDTH_PX / CELL_WIDTH_MULTIPLIER, + ) + const maxTextLenArray = Math.ceil( + MAX_SAMPLED_WIDTH_PX / ARRAY_CELL_WIDTH_MULTIPLIER, + ) + + return columns.map((col, colIdx) => { + const isArray = isArrayColumn(col) + const maxTextLen = isArray ? maxTextLenArray : maxTextLenRegular + const headerTextLen = Math.max( + col.name.length, + formatColumnType(col).length, + ) + const headerTextPx = Math.ceil(headerTextLen * CELL_WIDTH_MULTIPLIER) + let width = Math.max(MIN_COLUMN_WIDTH, headerTextPx + HEADER_CHROME_PX) + + for (const row of dataset) { + const val = row[colIdx] + const formatted = isArray + ? formatArrayValue(val, col) + : formatCellValue(val, col) + const displayLen = Math.min(formatted.length, maxTextLen) + width = Math.max(width, getCellWidth(displayLen, isArray)) + if (width >= MAX_SAMPLED_WIDTH_PX) { + width = MAX_SAMPLED_WIDTH_PX + break + } + } + return Math.min(width, MAX_SAMPLED_WIDTH_PX) + }) +} + +export const clampColumnWidths = ( + widths: number[], + containerWidth: number, +): number[] => { + const maxWidth = containerWidth * MAX_WIDTH_RATIO + return widths.map((width) => Math.min(width, maxWidth)) +} + +export const isLeftAligned = (type: string): boolean => + LEFT_ALIGNED_TYPES.has(type.toUpperCase()) + +export const formatColumnType = (col: ColumnDefinition): string => { + if (col.type !== "ARRAY") { + return col.type.toLowerCase() + } + const dim = col.dim ?? 1 + const elemType = col.elemType ?? "unknown" + if (dim > 2) { + return `ARRAY(${elemType.toUpperCase()},${dim})` + } + return elemType.toLowerCase() + "[]".repeat(dim) +} + +export const formatCellValue = ( + value: boolean | string | number | null, + col?: ColumnDefinition, + columnWidth?: number, +): string => { + if (value === null) return "null" + if (typeof value === "boolean") return value ? "true" : "false" + + if (col && isArrayColumn(col)) { + return formatArrayValue(value, col, columnWidth) + } + + if ( + col && + typeof value === "number" && + FLOAT_TYPES.has(col.type.toUpperCase()) && + Number.isInteger(value) + ) { + return value.toFixed(1) + } + + return String(value) +} + +export const formatCellValueForCopy = ( + value: boolean | string | number | null, + col?: ColumnDefinition, +): string => { + if (value === null) return "null" + + if (col && isArrayColumn(col)) { + return unescapeHtml(formatArrayFull(value, col)) + } + + return unescapeHtml(formatCellValue(value, col)) +} diff --git a/src/components/ResultGrid/resultPageMarkdown.test.ts b/src/components/ResultGrid/resultPageMarkdown.test.ts new file mode 100644 index 000000000..200ba1815 --- /dev/null +++ b/src/components/ResultGrid/resultPageMarkdown.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest" +import type { ColumnDefinition } from "../../utils/questdb/types" +import { buildResultPageMarkdown } from "./resultPageMarkdown" + +const col = ( + name: string, + type: string, + extra: Partial = {}, +): ColumnDefinition => ({ name, type, ...extra }) + +describe("buildResultPageMarkdown", () => { + it("renders a pipe-aligned table padded to the widest cell per column", () => { + // Given a two-column result and two loaded rows + const columns = [col("symbol", "SYMBOL"), col("price", "DOUBLE")] + const rows = [ + ["BTC", 65000], + ["ETH", 3200], + ] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then each column is padded to fit its header and values + expect(md).toBe( + [ + "| symbol | price |", + "| ------ | ------- |", + "| BTC | 65000.0 |", + "| ETH | 3200.0 |", + ].join("\n"), + ) + }) + + it("formats nulls and integer-valued floats like the grid does", () => { + // Given a float column with a null and an integer-valued float + const columns = [col("x", "DOUBLE")] + const rows = [[null], [1]] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then null renders as "null" and the float keeps its ".0" + expect(md).toBe(["| x |", "| ---- |", "| null |", "| 1.0 |"].join("\n")) + }) + + it("renders a QUERY PLAN result as a fenced code block", () => { + // Given a single QUERY PLAN column with two plan lines + const columns = [col("QUERY PLAN", "STRING")] + const rows = [["Async JIT Filter"], [" Row forward scan"]] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then it is a fenced block, one line per row, no table pipes + expect(md).toBe( + ["```", "Async JIT Filter", " Row forward scan", "```"].join("\n"), + ) + }) + + it("returns the header and separator only when no rows are loaded", () => { + // Given columns but an empty (unloaded) page + const columns = [col("a", "INT"), col("b", "INT")] + const rows: (string | number | boolean | null)[][] = [] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then only the header and separator are emitted + expect(md).toBe(["| a | b |", "| - | - |"].join("\n")) + }) + + it("unescapes HTML entities so exported text matches the grid", () => { + // Given a string column whose value carries HTML entities + const columns = [col("note", "VARCHAR")] + const rows = [["price>100"]] + + // When building the markdown + const md = buildResultPageMarkdown(columns, rows) + + // Then the cell is unescaped, matching what the grid displays + expect(md).toBe( + ["| note |", "| --------- |", "| price>100 |"].join("\n"), + ) + }) + + it("returns an empty string when there are no columns", () => { + // Given an empty result + // When building the markdown + // Then the output is empty + expect(buildResultPageMarkdown([], [])).toBe("") + }) +}) diff --git a/src/components/ResultGrid/resultPageMarkdown.ts b/src/components/ResultGrid/resultPageMarkdown.ts new file mode 100644 index 000000000..032441efe --- /dev/null +++ b/src/components/ResultGrid/resultPageMarkdown.ts @@ -0,0 +1,53 @@ +import type { ColumnDefinition } from "../../utils/questdb/types" +import { unescapeHtml } from "../../utils/escapeHtml" +import type { ResultGridRow } from "./types" +import { formatCellValueForCopy } from "./inlineGridUtils" + +const isQueryPlanResult = (columns: ColumnDefinition[]): boolean => + columns.length === 1 && columns[0].name === "QUERY PLAN" + +const buildQueryPlanMarkdown = (rows: ResultGridRow[]): string => { + const lines = ["```"] + for (const row of rows) { + const cell = row[0] + if (cell === null || cell === undefined) continue + lines.push(unescapeHtml(String(cell))) + } + lines.push("```") + return lines.join("\n") +} + +const renderRow = (cells: string[], widths: number[]): string => + `| ${cells.map((cell, i) => cell.padEnd(widths[i])).join(" | ")} |` + +const buildTableMarkdown = ( + columns: ColumnDefinition[], + rows: ResultGridRow[], +): string => { + const headers = columns.map((column) => column.name) + const widths = headers.map((header) => header.length) + + const formattedRows = rows.map((row) => + columns.map((column, i) => { + const text = formatCellValueForCopy(row[i] ?? null, column) + widths[i] = Math.max(widths[i], text.length) + return text + }), + ) + + const headerRow = renderRow(headers, widths) + const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |` + const dataRows = formattedRows.map((cells) => renderRow(cells, widths)) + + return [headerRow, separator, ...dataRows].join("\n") +} + +export const buildResultPageMarkdown = ( + columns: ColumnDefinition[], + rows: ResultGridRow[], +): string => { + if (columns.length === 0) return "" + return isQueryPlanResult(columns) + ? buildQueryPlanMarkdown(rows) + : buildTableMarkdown(columns, rows) +} diff --git a/src/components/ResultGrid/styles.ts b/src/components/ResultGrid/styles.ts new file mode 100644 index 000000000..27d7be88a --- /dev/null +++ b/src/components/ResultGrid/styles.ts @@ -0,0 +1,286 @@ +import styled, { css, keyframes } from "styled-components" +import { color } from "../../utils" +import { CopyButton } from "../CopyButton" +import { HEADER_HEIGHT, ROW_HEIGHT } from "./dimensions" + +export type DatasetRow = (boolean | string | number | null)[] + +export { HEADER_HEIGHT, ROW_HEIGHT } + +export const GridContainer = styled.div` + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + outline: none; + font-size: ${({ theme }) => theme.fontSize.xs}; + position: relative; +` + +export const ScrollContainer = styled.div<{ $scrollable: boolean }>` + flex: 1; + overflow: ${({ $scrollable }) => ($scrollable ? "auto" : "hidden")}; +` + +export const HeaderRow = styled.div<{ $shadowBottom: boolean }>` + display: flex; + background: ${color("backgroundDarker")}; + border-bottom: 1px solid ${color("selection")}; + flex-shrink: 0; + height: ${HEADER_HEIGHT}px; + box-shadow: ${({ $shadowBottom }) => + $shadowBottom ? "0 2px 4px rgba(0, 0, 0, 0.3)" : "none"}; + transition: box-shadow 0.15s; +` + +export const HeaderCell = styled.div<{ $align: string; $frozen?: boolean }>` + position: relative; + flex-shrink: 0; + padding: 0.5rem 1rem; + display: flex; + flex-direction: column; + justify-content: center; + user-select: none; + text-align: ${({ $align }) => $align}; + border-right: 1px solid ${color("selection")}; + /* Sticky-left: opaque background so scrolled-under headers don't show through. */ + ${({ $frozen }) => + $frozen && + css` + background: ${color("backgroundDarker")}; + justify-content: flex-start; + `} + + &:hover .header-copy-btn, + .header-copy-btn[data-copied="true"] { + visibility: visible; + } +` + +export const HeaderNameRow = styled.div<{ $align: string }>` + display: flex; + align-items: center; + flex-direction: ${({ $align }) => + $align === "right" ? "row-reverse" : "row"}; + justify-content: flex-start; + gap: 6px; +` + +export const HeaderName = styled.span` + color: ${color("cyan")}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + font-size: 1.4rem; +` + +export const HeaderType = styled.span` + color: ${color("gray2")}; + font-size: 1rem; + white-space: nowrap; + text-transform: lowercase; +` + +export const StyledCopyButton = styled(CopyButton)` + visibility: hidden; + flex-shrink: 0; + padding: 0; + + &:hover { + background: transparent !important; + } +` + +export const ColResizer = styled.div` + position: absolute; + right: -10px; + top: 0; + bottom: 0; + width: 20px; + cursor: col-resize; + touch-action: none; + user-select: none; + pointer-events: auto; + z-index: 2; + + &::after { + content: ""; + position: absolute; + left: 50%; + top: 25%; + transform: translateX(-50%); + width: 5px; + height: 50%; + border-radius: 2px; + background: transparent; + transition: background 0.1s; + } + + &:hover::after { + background: ${color("cyan")}; + } +` + +export const ResizerOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + height: ${HEADER_HEIGHT}px; + pointer-events: none; + z-index: 6; +` + +export const ResizeGhost = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: ${color("cyan")}; + pointer-events: none; + /* Above the resizer overlay (z-index 6) so the drag line isn't clipped. */ + z-index: 7; +` + +export const Row = styled.div<{ $active: boolean }>` + display: flex; + height: ${ROW_HEIGHT}px; + + ${({ $active }) => + $active && + css` + background: ${color("selectionDarker")}; + `} + + ${({ $active }) => + !$active && + css` + &:hover { + background: ${color("selectionDarker")}; + + [data-frozen="true"] { + background: ${color("selectionDarker")}; + } + } + `} +` + +const pulseAnim = keyframes` + 0% { box-shadow: #8be9fd 0 0 0 1px; } + 75% { box-shadow: rgba(241, 250, 140, 0) 0 0 0 16px; } +` + +export const Cell = styled.div<{ + $isNull: boolean + $isTimestamp: boolean + $isActive: boolean + $isPulsing: boolean + $frozen?: boolean + $rowActive?: boolean +}>` + flex-shrink: 0; + height: ${ROW_HEIGHT}px; + display: flex; + align-items: center; + padding: 0 0.6rem; + overflow: hidden; + font-size: 1.3rem; + color: ${({ $isNull, $isTimestamp }) => + $isNull ? "#939393" : $isTimestamp ? color("green") : color("foreground")}; + border-right: 1px solid ${color("selection")}; + border-bottom: 1px solid ${color("selection")}; + box-sizing: border-box; + /* contain: layout, not paint — paint would clip the copy-pulse glow. */ + contain: layout; + + ${({ $frozen, $rowActive }) => + $frozen && + css` + background: ${$rowActive + ? color("selectionDarker") + : color("background")}; + `} + + ${({ $isActive }) => + $isActive && + css` + background: ${color("tableSelection")}; + box-shadow: inset 0 0 0 1px ${color("cyan")}; + border-radius: 0.4rem; + `} + + ${({ $isPulsing }) => + $isPulsing && + css` + animation: ${pulseAnim} 1s ease-out; + `} +` + +export const CellText = styled.div` + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +export const FrozenShadow = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 16px; + background: linear-gradient(to right, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + pointer-events: none; + z-index: 3; +` + +// Narrow so the adjacent column's resizer stays reachable. +export const FreezeHandle = styled.div<{ + $dragging?: boolean + $flush?: boolean +}>` + position: absolute; + top: 0; + bottom: 0; + width: 8px; + margin-left: -4px; + cursor: col-resize; + touch-action: none; + user-select: none; + z-index: 5; + + &::after { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + transform: translateX(-50%); + background: transparent; + transition: background 0.1s; + } + + /* With nothing frozen the handle sits flush against the grid's left edge: no + centering margin (so it isn't clipped), and the indicator aligns to the + edge so it matches the drag ghost's 0-frozen position at x=0. */ + ${({ $flush }) => + $flush && + css` + margin-left: 0; + &::after { + left: 0; + transform: none; + } + `} + + /* While dragging, the ResizeGhost is the only indicator — the handle's own + hover bar would otherwise show as a redundant second ghost. */ + ${({ $dragging }) => + !$dragging && + css` + &:hover::after { + background: ${color("cyan")}; + } + `} +` diff --git a/src/components/ResultGrid/types.ts b/src/components/ResultGrid/types.ts new file mode 100644 index 000000000..b71e0d357 --- /dev/null +++ b/src/components/ResultGrid/types.ts @@ -0,0 +1,44 @@ +import type { + ColumnDefinition, + Timings, + Explain, +} from "../../utils/questdb/types" + +// Neutral DQL-result shape the grid reads from, free of feature-specific +// coupling so it stays reusable. +export type DqlQueryResult = { + columns: ColumnDefinition[] + dataset: (boolean | string | number | null)[][] + count: number + query: string + timestamp?: number + timings?: Timings + explain?: Explain +} + +export type ResultGridRow = (boolean | string | number | null)[] + +export type ResultGridDataSource = { + columns: ColumnDefinition[] + rowCount: number + designatedTimestamp: number + getRow: (index: number) => ResultGridRow | undefined + sampleRows: ResultGridRow[] + onVisibleRowsChange?: (range: { + firstIndex: number + lastIndex: number + direction: number + }) => void +} + +export const inMemoryDataSource = ( + columns: ColumnDefinition[], + dataset: ResultGridRow[], + designatedTimestamp = -1, +): ResultGridDataSource => ({ + columns, + rowCount: dataset.length, + designatedTimestamp, + getRow: (index) => dataset[index], + sampleRows: dataset, +}) diff --git a/src/components/ResultGrid/useContainerWidth.ts b/src/components/ResultGrid/useContainerWidth.ts new file mode 100644 index 000000000..4e6deb778 --- /dev/null +++ b/src/components/ResultGrid/useContainerWidth.ts @@ -0,0 +1,23 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from "react" + +export const useContainerWidth = (ref: RefObject): number => { + const [width, setWidth] = useState(800) + + useLayoutEffect(() => { + const measured = ref.current?.getBoundingClientRect().width + // A 0 width (not laid out yet) would collapse every column to zero. + if (measured) setWidth(measured) + }, [ref]) + + useEffect(() => { + if (!ref.current) return + const observer = new ResizeObserver(([entry]) => { + const measured = entry.contentRect.width + if (measured) setWidth(measured) + }) + observer.observe(ref.current) + return () => observer.disconnect() + }, [ref]) + + return width +} diff --git a/src/components/ResultGrid/useGridKeyboardNav.ts b/src/components/ResultGrid/useGridKeyboardNav.ts new file mode 100644 index 000000000..c1b974945 --- /dev/null +++ b/src/components/ResultGrid/useGridKeyboardNav.ts @@ -0,0 +1,202 @@ +import { useCallback, useState } from "react" +import type { ColumnDefinition } from "../../utils/questdb/types" +import { copyToClipboard } from "../../utils/copyToClipboard" +import { toast } from "../Toast" +import { formatCellValueForCopy } from "./inlineGridUtils" + +export type CellCoord = { row: number; col: number } + +type ScrollContext = { + scrollElement: HTMLElement + rowHeight: number + headerHeight: number + frozenWidth: number + frozenColCount: number + getColumnOffset: (col: number) => number + getColumnWidth: (col: number) => number +} + +const scrollCellIntoView = (cell: CellCoord, ctx: ScrollContext) => { + const { + scrollElement, + rowHeight, + headerHeight, + frozenWidth, + frozenColCount, + getColumnOffset, + getColumnWidth, + } = ctx + + const cellTop = headerHeight + cell.row * rowHeight + const cellBottom = cellTop + rowHeight + const viewTop = scrollElement.scrollTop + headerHeight + const viewBottom = scrollElement.scrollTop + scrollElement.clientHeight + + if (cellTop < viewTop) { + scrollElement.scrollTop = cellTop - headerHeight + } else if (cellBottom > viewBottom) { + scrollElement.scrollTop = cellBottom - scrollElement.clientHeight + } + + if (cell.col < frozenColCount) return + + const cellLeft = getColumnOffset(cell.col) + const cellRight = cellLeft + getColumnWidth(cell.col) + const viewLeft = scrollElement.scrollLeft + frozenWidth + const viewRight = scrollElement.scrollLeft + scrollElement.clientWidth + + if (cellLeft < viewLeft) { + scrollElement.scrollLeft = cellLeft - frozenWidth + } else if (cellRight > viewRight) { + scrollElement.scrollLeft = cellRight - scrollElement.clientWidth + } +} + +export const useGridKeyboardNav = ( + rowCount: number, + colCount: number, + getData: ( + row: number, + col: number, + ) => boolean | string | number | null | undefined, + getColumn: (col: number) => ColumnDefinition | undefined, + scrollContextRef: React.RefObject, + onCopy?: () => void, +) => { + const [focusedCell, setFocusedCell] = useState(null) + const [copyPulse, setCopyPulse] = useState(null) + + const moveTo = useCallback( + (row: number, col: number) => { + const next = { row, col } + setFocusedCell(next) + if (scrollContextRef.current) { + scrollCellIntoView(next, scrollContextRef.current) + } + }, + [scrollContextRef], + ) + + const onCellClick = useCallback((row: number, col: number) => { + setFocusedCell({ row, col }) + }, []) + + const onBlur = useCallback((e: React.FocusEvent) => { + const next = e.relatedTarget as Node | null + if (next === null || e.currentTarget.contains(next)) return + setFocusedCell(null) + }, []) + + const copyCell = useCallback( + (row: number, col: number) => { + const value = getData(row, col) + // The cell's page hasn't loaded yet + if (value === undefined) return + const text = formatCellValueForCopy(value, getColumn(col)) + onCopy?.() + void copyToClipboard(text).then(() => { + toast.success("Copied to clipboard") + setCopyPulse({ row, col }) + setTimeout(() => setCopyPulse(null), 1000) + }) + }, + [getData, getColumn, onCopy], + ) + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!focusedCell) return + + const { row, col } = focusedCell + + switch (e.key) { + case "ArrowUp": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(0, col) + } else if (row > 0) { + moveTo(row - 1, col) + } + break + case "ArrowDown": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(rowCount - 1, col) + } else if (row < rowCount - 1) { + moveTo(row + 1, col) + } + break + case "ArrowLeft": + e.preventDefault() + if (col > 0) moveTo(row, col - 1) + break + case "ArrowRight": + e.preventDefault() + if (col < colCount - 1) moveTo(row, col + 1) + break + case "Home": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(0, 0) + } else { + moveTo(row, 0) + } + break + case "End": + e.preventDefault() + if (e.metaKey || e.ctrlKey) { + moveTo(rowCount - 1, colCount - 1) + } else { + moveTo(row, colCount - 1) + } + break + case "PageUp": { + e.preventDefault() + const ctx = scrollContextRef.current + if (ctx) { + const pageRows = Math.floor( + (ctx.scrollElement.clientHeight - ctx.headerHeight) / + ctx.rowHeight, + ) + moveTo(Math.max(0, row - pageRows), col) + } + break + } + case "PageDown": { + e.preventDefault() + const ctx = scrollContextRef.current + if (ctx) { + const pageRows = Math.floor( + (ctx.scrollElement.clientHeight - ctx.headerHeight) / + ctx.rowHeight, + ) + moveTo(Math.min(rowCount - 1, row + pageRows), col) + } + break + } + case "c": + if (e.metaKey || e.ctrlKey) { + e.preventDefault() + copyCell(row, col) + } + break + case "Insert": + if (e.ctrlKey) { + e.preventDefault() + copyCell(row, col) + } + break + } + }, + [focusedCell, rowCount, colCount, copyCell, moveTo, scrollContextRef], + ) + + return { + focusedCell, + setFocusedCell, + copyPulse, + onCellClick, + onKeyDown, + onBlur, + } +} diff --git a/src/components/ResultGrid/useScrollShadows.ts b/src/components/ResultGrid/useScrollShadows.ts new file mode 100644 index 000000000..841906dd3 --- /dev/null +++ b/src/components/ResultGrid/useScrollShadows.ts @@ -0,0 +1,27 @@ +import { useCallback, useRef, useState, type RefObject } from "react" + +// Tracks whether `ref` is scrolled past its top/left edge, so the header and +// frozen-column shadows can show. +export const useScrollShadows = (ref: RefObject) => { + const [scrolledDown, setScrolledDown] = useState(false) + const [shadowLeft, setShadowLeft] = useState(false) + const scrolledDownRef = useRef(false) + const shadowLeftRef = useRef(false) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const down = el.scrollTop > 0 + const left = el.scrollLeft > 0 + if (down !== scrolledDownRef.current) { + scrolledDownRef.current = down + setScrolledDown(down) + } + if (left !== shadowLeftRef.current) { + shadowLeftRef.current = left + setShadowLeft(left) + } + }, [ref]) + + return { scrolledDown, shadowLeft, handleScroll } +} diff --git a/src/components/ResultGrid/virtualRowMapping.test.ts b/src/components/ResultGrid/virtualRowMapping.test.ts new file mode 100644 index 000000000..23ed4f7e8 --- /dev/null +++ b/src/components/ResultGrid/virtualRowMapping.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest" +import { + LEAP_TAIL_ROWS, + MAX_VIRTUAL_ROWS, + toAbsoluteIndex, + toVisibleAbsoluteRange, +} from "./virtualRowMapping" + +describe("toAbsoluteIndex", () => { + it("is identity when the result fits within the height cap", () => { + expect(toAbsoluteIndex(0, 100)).toBe(0) + expect(toAbsoluteIndex(42, 100)).toBe(42) + expect(toAbsoluteIndex(99, 100)).toBe(99) + expect(toAbsoluteIndex(7, MAX_VIRTUAL_ROWS)).toBe(7) + }) + + it("keeps the head rows 1:1 when the result exceeds the cap", () => { + const rowCount = MAX_VIRTUAL_ROWS * 3 + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + + expect(toAbsoluteIndex(0, rowCount)).toBe(0) + expect(toAbsoluteIndex(headCount - 1, rowCount)).toBe(headCount - 1) + }) + + it("maps the tail rows onto the end of the result", () => { + const rowCount = MAX_VIRTUAL_ROWS * 3 + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + + // The first tail row jumps to the last LEAP_TAIL_ROWS window of the result. + expect(toAbsoluteIndex(headCount, rowCount)).toBe(rowCount - LEAP_TAIL_ROWS) + // The very last virtual row resolves to the very last real row. + expect(toAbsoluteIndex(MAX_VIRTUAL_ROWS - 1, rowCount)).toBe(rowCount - 1) + }) +}) + +describe("toVisibleAbsoluteRange", () => { + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + + it("maps both ends directly when the result fits within the height cap", () => { + // Given a result below the cap + const rowCount = 5000 + + // When the visible window is anywhere in it + const range = toVisibleAbsoluteRange(100, 130, rowCount) + + // Then the range is the identity mapping + expect(range).toEqual({ firstIndex: 100, lastIndex: 130 }) + }) + + it("maps a window fully inside the head 1:1", () => { + // Given a result past the cap + const rowCount = MAX_VIRTUAL_ROWS * 3 + + // When the window ends before the leap + const range = toVisibleAbsoluteRange( + headCount - 50, + headCount - 20, + rowCount, + ) + + // Then both ends stay 1:1 + expect(range).toEqual({ + firstIndex: headCount - 50, + lastIndex: headCount - 20, + }) + }) + + it("maps a window fully inside the tail onto the end of the result", () => { + // Given a result past the cap + const rowCount = MAX_VIRTUAL_ROWS * 3 + + // When the window sits entirely in the leap tail + const range = toVisibleAbsoluteRange( + headCount + 10, + headCount + 40, + rowCount, + ) + + // Then both ends resolve into the result's last LEAP_TAIL_ROWS window + expect(range).toEqual({ + firstIndex: rowCount - LEAP_TAIL_ROWS + 10, + lastIndex: rowCount - LEAP_TAIL_ROWS + 40, + }) + }) + + it("resolves a leap-straddling window to the head when it holds more rows", () => { + // Given a result past the cap + const rowCount = MAX_VIRTUAL_ROWS * 3 + + // When most of the window is above the leap + const range = toVisibleAbsoluteRange( + headCount - 20, + headCount + 5, + rowCount, + ) + + // Then the range is the contiguous head segment, never spanning the gap + expect(range).toEqual({ + firstIndex: headCount - 20, + lastIndex: headCount - 1, + }) + }) + + it("resolves a leap-straddling window to the tail when it holds more rows", () => { + // Given a result past the cap + const rowCount = MAX_VIRTUAL_ROWS * 3 + + // When most of the window is below the leap + const range = toVisibleAbsoluteRange( + headCount - 5, + headCount + 20, + rowCount, + ) + + // Then the range is the contiguous tail segment, never spanning the gap + expect(range).toEqual({ + firstIndex: rowCount - LEAP_TAIL_ROWS, + lastIndex: rowCount - LEAP_TAIL_ROWS + 20, + }) + }) +}) diff --git a/src/components/ResultGrid/virtualRowMapping.ts b/src/components/ResultGrid/virtualRowMapping.ts new file mode 100644 index 000000000..ccfaead34 --- /dev/null +++ b/src/components/ResultGrid/virtualRowMapping.ts @@ -0,0 +1,48 @@ +import { ROW_HEIGHT } from "./dimensions" + +// Browsers cap element height (~17.9M px in Firefox); stay well under it. +export const MAX_CANVAS_PX = 10_000_000 +export const MAX_VIRTUAL_ROWS = Math.floor(MAX_CANVAS_PX / ROW_HEIGHT) +// Past the cap, the last LEAP_TAIL_ROWS rows jump to the result's tail so the +// end stays reachable; the head scrolls 1:1. +export const LEAP_TAIL_ROWS = 1000 + +export const toAbsoluteIndex = ( + virtualIndex: number, + rowCount: number, +): number => { + if (rowCount <= MAX_VIRTUAL_ROWS) return virtualIndex + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + if (virtualIndex < headCount) return virtualIndex + return rowCount - (MAX_VIRTUAL_ROWS - virtualIndex) +} + +// A window straddling the leap is not contiguous in absolute space, so it +// resolves to the segment (head or tail) holding more of the visible rows. +export const toVisibleAbsoluteRange = ( + firstVirtual: number, + lastVirtual: number, + rowCount: number, +): { firstIndex: number; lastIndex: number } => { + const headCount = MAX_VIRTUAL_ROWS - LEAP_TAIL_ROWS + const straddlesLeap = + rowCount > MAX_VIRTUAL_ROWS && + firstVirtual < headCount && + lastVirtual >= headCount + + if (!straddlesLeap) { + return { + firstIndex: toAbsoluteIndex(firstVirtual, rowCount), + lastIndex: toAbsoluteIndex(lastVirtual, rowCount), + } + } + + const headRows = headCount - firstVirtual + const tailRows = lastVirtual - headCount + 1 + return headRows >= tailRows + ? { firstIndex: firstVirtual, lastIndex: headCount - 1 } + : { + firstIndex: toAbsoluteIndex(headCount, rowCount), + lastIndex: toAbsoluteIndex(lastVirtual, rowCount), + } +} diff --git a/src/js/console/grid.js b/src/js/console/grid.js index b8695f67f..1f84ab9a1 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -26,6 +26,7 @@ import { unescapeHtml } from "../../utils/escapeHtml" import { toast } from "../../components" import { trackEvent } from "../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { buildResultPageMarkdown } from "../../components/ResultGrid/resultPageMarkdown" const hashString = (str) => { let hash = 0 @@ -792,92 +793,13 @@ export function grid(rootElement, _paginationFn, id) { } } - function getQueryPlanAsMarkdown() { - let lines = ["```"] - rows.forEach((row) => { - if (row.style.display === "flex") { - const col = row.querySelector(".qg-c") - if (col) { - lines.push(col.textContent) - } - } - }) - lines.push("```") - return lines.join("\n") - } - - function getResultSetGridAsMarkdown() { - // first, we get a starting width, based on the column header - // this is necessary to get a properly formatted, pipe-aligned table - - // format is: title\ntype\ntitle2\ntype2 - // therefore we need to skip alternate entries - let header_splits = header.innerText.split(/\n/).filter((elem, index) => { - return index % 2 === 0 - }) - let column_widths = Array(header_splits.length).fill(0) - - for (const [i, header_split] of header_splits.entries()) { - // tslint:disable-next-line:no-bitwise - column_widths[i] = Math.max(column_widths[i], header_split.length) - } - - // then we loop over our rows to check how wide it needs to be according to the data - for (const row of rows) { - let row_splits = row.innerText.split(/\n/) - for (const [i, row_split] of row_splits.entries()) { - column_widths[i] = Math.max(column_widths[i], row_split.length) - } - } - - // now we know the widths, we need to construct the header row - let header_row_builder = ["|"] - - for (const [i, header_split] of header_splits.entries()) { - header_row_builder.push(header_split.padEnd(column_widths[i])) - header_row_builder.push("|") - } - - let header_row = header_row_builder.join(" ") - - // now we need the pad row - let pad_row_builder = ["|"] - for (const [i, header_split] of header_splits.entries()) { - pad_row_builder.push("-".repeat(column_widths[i])) - pad_row_builder.push("|") - } - - let pad_row = pad_row_builder.join(" ") - - let data_rows_builder = [] - - for (const row of rows) { - let row_splits = row.innerText.split(/\n/) - - // sometimes we get arrays like this: [""] in rows - // this usually happens at the end of the result set - // we don't want them... - if (row_splits.length === 1 && row_splits[0] === "") { - continue - } - let data_row_builder = ["|"] - - for (const [i, row_split] of row_splits.entries()) { - data_row_builder.push(row_split.padEnd(column_widths[i])) - data_row_builder.push("|") - } - data_rows_builder.push(data_row_builder.join(" ")) - } - - let data_rows = data_rows_builder.join("\n") - - return [header_row, pad_row, data_rows].join("\n") - } - + // Copies the single page currently in view function getResultAsMarkdown() { - return header.innerText === "QUERY PLAN\nstring" - ? getQueryPlanAsMarkdown() - : getResultSetGridAsMarkdown() + const currentPage = Math.floor( + Math.floor(viewport.scrollTop / rh) / pageSize, + ) + const pageRows = data[currentPage] ?? [] + return buildResultPageMarkdown(columns, pageRows) } function colFreezeToggle() { diff --git a/src/providers/LocalStorageProvider/index.tsx b/src/providers/LocalStorageProvider/index.tsx index 7f53aa549..1008f1d40 100644 --- a/src/providers/LocalStorageProvider/index.tsx +++ b/src/providers/LocalStorageProvider/index.tsx @@ -22,10 +22,16 @@ * ******************************************************************************/ -import React, { createContext, useState, useContext, useCallback } from "react" +import React, { + createContext, + useState, + useContext, + useCallback, + useEffect, +} from "react" import { getValue, setValue } from "../../utils/localStorage" import { StoreKey } from "../../utils/localStorage/types" -import { parseInteger } from "./utils" +import { parseInteger, parseBoolean } from "./utils" import { AiAssistantSettings, LocalConfig, @@ -46,6 +52,7 @@ const defaultConfig: LocalConfig = { resultsSplitterBasis: 350, exampleQueriesVisited: false, autoRefreshTables: true, + useNewGrid: true, aiAssistantSettings: DEFAULT_AI_ASSISTANT_SETTINGS, leftPanelState: { type: LeftPanelType.DATASOURCES, @@ -62,6 +69,7 @@ type ContextProps = { updateSettings: (key: StoreKey, value: SettingsType) => void exampleQueriesVisited: boolean autoRefreshTables: boolean + useNewGrid: boolean leftPanelState: LeftPanelState updateLeftPanelState: (state: LeftPanelState) => void aiAssistantSettings: AiAssistantSettings @@ -77,6 +85,7 @@ const defaultValues: ContextProps = { updateSettings: (_key: StoreKey, _value: SettingsType) => undefined, exampleQueriesVisited: false, autoRefreshTables: true, + useNewGrid: true, leftPanelState: defaultConfig.leftPanelState, updateLeftPanelState: (_state: LeftPanelState) => undefined, aiAssistantSettings: defaultConfig.aiAssistantSettings, @@ -120,6 +129,35 @@ export const LocalStorageProvider = ({ : defaultConfig.autoRefreshTables, ) + const getInitialNewGrid = (): boolean => { + const param = new URLSearchParams(window.location.search).get("useNewGrid") + if (param === "1" || param === "0") { + const value = param === "1" + setValue(StoreKey.USE_NEW_GRID, String(value)) + return value + } + return parseBoolean( + getValue(StoreKey.USE_NEW_GRID), + defaultConfig.useNewGrid, + ) + } + + const [useNewGrid, setUseNewGrid] = useState(getInitialNewGrid) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (!params.has("useNewGrid")) return + params.delete("useNewGrid") + const query = params.toString() + window.history.replaceState( + {}, + "", + window.location.pathname + + (query ? `?${query}` : "") + + window.location.hash, + ) + }, []) + const getLeftPanelState = (): LeftPanelState => { const stored = getValue(StoreKey.LEFT_PANEL_STATE) if (stored) { @@ -213,6 +251,9 @@ export const LocalStorageProvider = ({ case StoreKey.AUTO_REFRESH_TABLES: setAutoRefreshTables(value === "true") break + case StoreKey.USE_NEW_GRID: + setUseNewGrid(value === "true") + break case StoreKey.AI_ASSISTANT_SETTINGS: setAiAssistantSettings(getAiAssistantSettings()) break @@ -229,6 +270,7 @@ export const LocalStorageProvider = ({ updateSettings, exampleQueriesVisited, autoRefreshTables, + useNewGrid, leftPanelState, updateLeftPanelState, aiAssistantSettings, diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts index 48e55986d..144476236 100644 --- a/src/providers/LocalStorageProvider/types.ts +++ b/src/providers/LocalStorageProvider/types.ts @@ -39,6 +39,7 @@ export type LocalConfig = { resultsSplitterBasis: number exampleQueriesVisited: boolean autoRefreshTables: boolean + useNewGrid: boolean leftPanelState: LeftPanelState aiAssistantSettings: AiAssistantSettings aiChatPanelWidth: number diff --git a/src/scenes/Result/ResultGridAdapter.tsx b/src/scenes/Result/ResultGridAdapter.tsx new file mode 100644 index 000000000..25a6a89fa --- /dev/null +++ b/src/scenes/Result/ResultGridAdapter.tsx @@ -0,0 +1,186 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from "react" +import styled from "styled-components" +import { + ResultGrid, + buildResultPageMarkdown, + type ResultGridHandle, + type ResultGridRow, +} from "../../components/ResultGrid" +import type { ColumnDefinition } from "../../utils/questdb/types" +import type { IQuestDBGrid } from "../../js/console/grid" +import { usePagedDataSource, type PaginationFn } from "./usePagedDataSource" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { + loadColumnLayout, + saveColumnLayout, + removeColumnLayout, +} from "./columnLayoutStore" + +const AdapterRoot = styled.div<{ $visible: boolean }>` + display: ${({ $visible }) => ($visible ? "flex" : "none")}; + flex-direction: column; + flex: 1; + width: 100%; + min-height: 0; +` + +// Narrows IQuestDBGrid.setData's `any` to the fields we read. +type DqlResultInput = { + columns: ColumnDefinition[] + dataset: ResultGridRow[] + count: number + query: string + timestamp?: number +} + +type ResultGridAdapterProps = { + isFocused?: boolean + paginationFn?: PaginationFn +} + +// Backs the legacy IQuestDBGrid surface the console's Result scene drives with +// the neutral React ResultGrid and a server-paged data source. +export const ResultGridAdapter = forwardRef< + IQuestDBGrid, + ResultGridAdapterProps +>(({ isFocused = true, paginationFn }, ref) => { + const { dataSource, setResult, getSQL, getCurrentPageRows, hasData } = + usePagedDataSource(paginationFn) + + const [visible, setVisible] = useState(true) + const [runToken, setRunToken] = useState(0) + const [restoredSizing, setRestoredSizing] = useState>( + {}, + ) + const [restoredOrder, setRestoredOrder] = useState([]) + const [restoredPinned, setRestoredPinned] = useState([]) + + const rootRef = useRef(null) + const gridImperativeRef = useRef(null) + // Current result's columns, for keying the persisted layout on commit. + const columnsRef = useRef([]) + const listenersRef = useRef void>>>( + new Map(), + ) + + const emit = useCallback((name: string, detail?: unknown) => { + const set = listenersRef.current.get(name) + if (!set) return + const event = new CustomEvent(name, { detail }) + set.forEach((fn) => fn(event)) + }, []) + + const handleSizingCommit = useCallback((sizing: Record) => { + saveColumnLayout(columnsRef.current, { columnSizing: sizing }) + }, []) + + const handleOrderCommit = useCallback((order: string[]) => { + saveColumnLayout(columnsRef.current, { columnOrder: order }) + }, []) + + const handleResetLayout = useCallback(() => { + removeColumnLayout(columnsRef.current) + }, []) + + const handlePinnedColumnsCommit = useCallback( + (pinnedLeft: string[]) => { + saveColumnLayout(columnsRef.current, { pinnedColumns: pinnedLeft }) + emit("freeze.state", { freezeLeft: pinnedLeft.length }) + }, + [emit], + ) + + useImperativeHandle( + ref, + (): IQuestDBGrid => ({ + setData: (incoming: DqlResultInput) => { + setResult({ + columns: incoming.columns, + dataset: incoming.dataset, + count: incoming.count, + query: incoming.query, + timestamp: incoming.timestamp, + }) + setRunToken((token) => token + 1) + columnsRef.current = incoming.columns + const layout = loadColumnLayout(incoming.columns) + setRestoredSizing(layout?.columnSizing ?? {}) + setRestoredOrder(layout?.columnOrder ?? []) + const pinnedLeft = layout?.pinnedColumns ?? [] + setRestoredPinned(pinnedLeft) + // Sync the toolbar's freeze button to the restored layout. + emit("freeze.state", { freezeLeft: pinnedLeft.length }) + }, + + getSQL: () => getSQL(), + + focus: () => { + rootRef.current?.querySelector('[role="grid"]')?.focus() + }, + + show: () => setVisible(true), + + hide: () => setVisible(false), + + // No-op: React + the grid's ResizeObserver handle layout. + render: () => undefined, + + addEventListener: ( + eventName: string, + fn: (event: CustomEvent) => void, + ) => { + const set = listenersRef.current.get(eventName) ?? new Set() + set.add(fn) + listenersRef.current.set(eventName, set) + }, + + clearCustomLayout: () => gridImperativeRef.current?.resetLayout(), + shuffleFocusedColumnToFront: () => + gridImperativeRef.current?.shuffleFocusedColumnToFront(), + toggleFreezeLeft: () => gridImperativeRef.current?.toggleFreezeLeft(), + + getResultAsMarkdown: () => + buildResultPageMarkdown(columnsRef.current, getCurrentPageRows()), + }), + [setResult, getSQL, getCurrentPageRows, emit], + ) + + return ( + + {hasData && ( + emit("yield.focus")} + onResetLayout={handleResetLayout} + onSelectionChange={(hasSelection) => + emit("selection.change", { hasSelection }) + } + onCellCopy={() => void trackEvent(ConsoleEvent.GRID_CELL_COPY)} + onColumnCopy={() => void trackEvent(ConsoleEvent.GRID_COLUMN_COPY)} + /> + )} + + ) +}) + +ResultGridAdapter.displayName = "ResultGridAdapter" diff --git a/src/scenes/Result/benchmarkMock.ts b/src/scenes/Result/benchmarkMock.ts new file mode 100644 index 000000000..cf295fc28 --- /dev/null +++ b/src/scenes/Result/benchmarkMock.ts @@ -0,0 +1,75 @@ +// Dev-only A/B benchmarking harness for the two result grids (legacy grid.js vs +// the React ResultGrid). Everything here is inert unless localStorage holds +// "mock.pagination" === "true", so a normal session is unaffected and the code +// can ship without changing behaviour. See BENCHMARK.md for the full recipe. +// +// It synthesises a result of any size (rows x cols) with zero network: one canned +// page of constant rows is built once and served for every fetch behind a fixed +// 10ms latency. That removes API/network variance and per-fetch generation cost, +// leaving the grid's render/scroll work as the only variable both grids share. +import type { ColumnDefinition } from "../../utils/questdb/types" +import type { ResultGridRow } from "../../components/ResultGrid" +import { PAGE_SIZE } from "./nextPageWindow" + +const MOCK_PAGINATION_KEY = "mock.pagination" +const PAGE_LATENCY_MS = 10 + +export const isMockPagination = (): boolean => + localStorage.getItem(MOCK_PAGINATION_KEY) === "true" + +export type MockSeed = { + columns: ColumnDefinition[] + dataset: ResultGridRow[] + count: number + query: string + timestamp: number +} + +let cannedPage: ResultGridRow[] = [] + +const buildColumns = (cols: number): ColumnDefinition[] => + Array.from({ length: cols }, (_, i) => ({ name: `c${i}`, type: "SYMBOL" })) + +// Self-describing cell values: a cell shows "r{row}c{col}" so a benchmark can +// assert each rendered cell holds the value for its own position (no stale or +// column-misaligned data). The canned page repeats every PAGE_SIZE rows, so an +// absolute row R shows the value for row R % PAGE_SIZE. +const buildRow = (row: number, columns: ColumnDefinition[]): ResultGridRow => + columns.map((_, c) => `r${row}c${c}`) + +const buildCannedPage = (columns: ColumnDefinition[]): ResultGridRow[] => + Array.from({ length: PAGE_SIZE }, (_, row) => buildRow(row, columns)) + +export const seedMock = (rows: number, cols: number): MockSeed => { + const columns = buildColumns(cols) + cannedPage = buildCannedPage(columns) + return { + columns, + dataset: cannedPage.slice(), + count: rows, + query: `mock://${rows}x${cols}`, + timestamp: -1, + } +} + +// Mirrors the real paginationFn's contract: lo is 1-based inclusive, hi is +// inclusive, so the page spans (hi - lo + 1) rows. The two-page renderer splices +// the outer array in place, so each call returns a fresh outer array of shared +// (immutable) canned rows — O(rows), not O(rows * cols). +export const mockPaginate = ( + lo: number, + hi: number, + rendererFn: (data: { dataset: ResultGridRow[] }) => void, +): void => { + const rowsNeeded = Math.max(0, hi - lo + 1) + const dataset: ResultGridRow[] = [] + while (dataset.length < rowsNeeded && cannedPage.length > 0) { + const remaining = rowsNeeded - dataset.length + dataset.push( + ...(remaining >= cannedPage.length + ? cannedPage + : cannedPage.slice(0, remaining)), + ) + } + setTimeout(() => rendererFn({ dataset }), PAGE_LATENCY_MS) +} diff --git a/src/scenes/Result/columnLayoutStore.test.ts b/src/scenes/Result/columnLayoutStore.test.ts new file mode 100644 index 000000000..108ba06d4 --- /dev/null +++ b/src/scenes/Result/columnLayoutStore.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach } from "vitest" +import type { ColumnDefinition } from "../../utils/questdb/types" +import { + loadColumnLayout, + removeColumnLayout, + saveColumnLayout, +} from "./columnLayoutStore" + +// The store reads/writes this single localStorage entry. +const STORAGE_KEY = "result.grid.layout" +const LRU_MAX = 50 + +const installMemoryLocalStorage = () => { + const store: Record = {} + globalThis.localStorage = { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value + }, + removeItem: (key: string) => { + delete store[key] + }, + clear: () => { + Object.keys(store).forEach((k) => delete store[k]) + }, + key: () => null, + length: 0, + } as Storage +} + +const columnSet = (id: number): ColumnDefinition[] => [ + { name: `c${id}`, type: "INT" }, +] + +beforeEach(() => { + installMemoryLocalStorage() +}) + +describe("columnLayoutStore", () => { + it("round-trips a saved layout for the same column set", () => { + // Given a saved sizing for a column set + const columns = columnSet(1) + saveColumnLayout(columns, { columnSizing: { col_0: 200 } }) + + // When loading it back + const loaded = loadColumnLayout(columns) + + // Then the same layout is returned + expect(loaded).toEqual({ columnSizing: { col_0: 200 } }) + }) + + it("merges partial layouts for the same columns", () => { + // Given a sizing already saved + const columns = columnSet(1) + saveColumnLayout(columns, { columnSizing: { col_0: 200 } }) + + // When a separate order is saved for the same columns + saveColumnLayout(columns, { columnOrder: ["col_1", "col_0"] }) + + // Then both are preserved + expect(loadColumnLayout(columns)).toEqual({ + columnSizing: { col_0: 200 }, + columnOrder: ["col_1", "col_0"], + }) + }) + + it("keys layouts by column name and type", () => { + // Given a layout saved for one column set + saveColumnLayout(columnSet(1), { pinnedColumns: ["col_0"] }) + + // When loaded with a structurally identical set vs a different one + const sameShape = loadColumnLayout([{ name: "c1", type: "INT" }]) + const otherShape = loadColumnLayout(columnSet(2)) + + // Then only the matching shape resolves + expect(sameShape).toEqual({ pinnedColumns: ["col_0"] }) + expect(otherShape).toBeNull() + }) + + it("treats a column type change as a different layout", () => { + // Given a layout saved for an INT column + saveColumnLayout([{ name: "c1", type: "INT" }], { + columnSizing: { col_0: 120 }, + }) + + // When the same-named column is now a LONG + const loaded = loadColumnLayout([{ name: "c1", type: "LONG" }]) + + // Then no layout is found + expect(loaded).toBeNull() + }) + + it("returns null and skips persistence for an empty column set", () => { + // Given no columns + // When saving and loading + saveColumnLayout([], { columnSizing: { col_0: 100 } }) + + // Then nothing is stored or returned + expect(loadColumnLayout([])).toBeNull() + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + }) + + it("removes a stored layout", () => { + // Given a saved layout + const columns = columnSet(1) + saveColumnLayout(columns, { columnSizing: { col_0: 200 } }) + + // When it is removed + removeColumnLayout(columns) + + // Then it can no longer be loaded + expect(loadColumnLayout(columns)).toBeNull() + }) + + it("tolerates corrupted storage", () => { + // Given a malformed store entry + localStorage.setItem(STORAGE_KEY, "{not valid json") + + // When loading + // Then it degrades to null instead of throwing + expect(() => loadColumnLayout(columnSet(1))).not.toThrow() + expect(loadColumnLayout(columnSet(1))).toBeNull() + }) + + it("evicts the least-recently-saved layout past the cap", () => { + // Given one more than the cap of distinct column sets saved in order + for (let i = 0; i <= LRU_MAX; i++) { + saveColumnLayout(columnSet(i), { columnSizing: { col_0: i } }) + } + + // When inspecting the oldest and newest + // Then the first is evicted and the last survives + expect(loadColumnLayout(columnSet(0))).toBeNull() + expect(loadColumnLayout(columnSet(LRU_MAX))).toEqual({ + columnSizing: { col_0: LRU_MAX }, + }) + }) + + it("bumps recency on re-save so an old layout survives eviction", () => { + // Given the cap is filled + for (let i = 0; i < LRU_MAX; i++) { + saveColumnLayout(columnSet(i), { columnSizing: { col_0: i } }) + } + + // And the oldest entry is re-saved (becoming most recent) + saveColumnLayout(columnSet(0), { columnSizing: { col_0: 999 } }) + + // When one more new set pushes past the cap + saveColumnLayout(columnSet(LRU_MAX), { columnSizing: { col_0: LRU_MAX } }) + + // Then the re-saved entry survives and the next-oldest is evicted instead + expect(loadColumnLayout(columnSet(0))).toEqual({ + columnSizing: { col_0: 999 }, + }) + expect(loadColumnLayout(columnSet(1))).toBeNull() + }) +}) diff --git a/src/scenes/Result/columnLayoutStore.ts b/src/scenes/Result/columnLayoutStore.ts new file mode 100644 index 000000000..091f0669a --- /dev/null +++ b/src/scenes/Result/columnLayoutStore.ts @@ -0,0 +1,71 @@ +import type { ColumnDefinition } from "../../utils/questdb/types" + +const STORAGE_KEY = "result.grid.layout" +const LRU_MAX = 50 + +export type ColumnLayout = { + columnSizing?: Record + columnOrder?: string[] + pinnedColumns?: string[] +} + +type LayoutStore = Record + +const hashColumnSet = (columns: ColumnDefinition[]): string => { + const str = JSON.stringify( + columns.map((c) => ({ name: c.name, type: c.type })), + ) + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash + char) | 0 + } + return (hash >>> 0).toString(36) +} + +const readStore = (): LayoutStore => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") as LayoutStore + } catch { + return {} + } +} + +const writeStore = (store: LayoutStore): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) + } catch { + // ignore quota / serialization errors — persistence is best-effort + } +} + +export const loadColumnLayout = ( + columns: ColumnDefinition[], +): ColumnLayout | null => { + if (!columns.length) return null + return readStore()[hashColumnSet(columns)] ?? null +} + +export const removeColumnLayout = (columns: ColumnDefinition[]): void => { + if (!columns.length) return + const store = readStore() + delete store[hashColumnSet(columns)] + writeStore(store) +} + +export const saveColumnLayout = ( + columns: ColumnDefinition[], + layout: ColumnLayout, +): void => { + if (!columns.length) return + const key = hashColumnSet(columns) + const store = readStore() + const existing = store[key] + delete store[key] + store[key] = { ...existing, ...layout } + const keys = Object.keys(store) + for (let i = 0; i < keys.length - LRU_MAX; i++) { + delete store[keys[i]] + } + writeStore(store) +} diff --git a/src/scenes/Result/index.tsx b/src/scenes/Result/index.tsx index 2fb40f8ed..db375c337 100644 --- a/src/scenes/Result/index.tsx +++ b/src/scenes/Result/index.tsx @@ -23,7 +23,13 @@ ******************************************************************************/ import $ from "jquery" -import React, { useContext, useEffect, useRef, useState } from "react" +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react" import { useDispatch, useSelector } from "react-redux" import styled from "styled-components" import { Download2, Refresh } from "@styled-icons/remix-line" @@ -46,7 +52,7 @@ import { Tooltip, } from "../../components" import { actions, selectors } from "../../store" -import { color, ErrorResult, QueryRawResult, RawErrorResult } from "../../utils" +import { color, ErrorResult, RawErrorResult } from "../../utils" import * as QuestDB from "../../utils/questdb" import { ResultViewMode } from "scenes/Console/types" import type { IQuestDBGrid } from "../../js/console/grid.js" @@ -55,13 +61,18 @@ import { EventType } from "../../modules/EventBus/types" import { QuestContext } from "../../providers" import { LINE_NUMBER_HARD_LIMIT } from "../Editor/Monaco" import { QueryInNotification } from "../Editor/Monaco/query-in-notification" -import { NotificationType } from "../../store/Query/types" +import { NotificationType, RunningType } from "../../store/Query/types" import { copyToClipboard } from "../../utils/copyToClipboard" import { toast } from "../../components" import { useQueryExecutionState } from "../../hooks/useQueryExecutionState" import { API_VERSION } from "../../consts" import { trackEvent } from "../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { useLocalStorage } from "../../providers/LocalStorageProvider" +import { ResultGridAdapter } from "./ResultGridAdapter" +import { type PaginationFn } from "./usePagedDataSource" +import { isMockPagination, mockPaginate, seedMock } from "./benchmarkMock" +import type { ResultGridRow } from "../../components/ResultGrid" const Root = styled.div` display: flex; @@ -148,42 +159,65 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const { quest, questExecution } = useContext(QuestContext) const [count, setCount] = useState() const result = useSelector(selectors.query.getResult) + const running = useSelector(selectors.query.getRunning) const { active: activeQueryExecution } = useQueryExecutionState() const activeSidebar = useSelector(selectors.console.getActiveSidebar) - const gridRef = useRef() + const gridRef = useRef(null) + const runningRef = useRef(running) const [gridFreezeLeftState, setGridFreezeLeftState] = useState(0) const [gridHasSelection, setGridHasSelection] = useState(false) const [downloadMenuActive, setDownloadMenuActive] = useState(false) + const { useNewGrid } = useLocalStorage() const dispatch = useDispatch() - useEffect(() => { - const _grid = grid( - document.getElementById("grid"), - async function (sql, lo, hi, rendererFn: (data: QueryRawResult) => void) { - try { - const result = await quest.queryRaw(sql, { - limit: `${lo},${hi}`, - nm: true, - }) - if (result.type === QuestDB.Type.DQL) { - rendererFn(result) - } - } catch (err) { - // Order of actions is important - dispatch( - actions.query.addNotification({ - query: `${sql}@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}`, - content: {(err as ErrorResult).error}, - sideContent: , - type: NotificationType.ERROR, - updateActiveNotification: true, - }), - ) + // Shared by both grids. On a failed fetch it surfaces a notification and never + // calls the renderer, leaving the failing page unloaded. + const paginationFn = useCallback( + async (sql, lo, hi, rendererFn) => { + if (isMockPagination()) { + mockPaginate( + lo, + hi, + rendererFn as unknown as (d: { dataset: ResultGridRow[] }) => void, + ) + return + } + try { + const result = await quest.queryRaw(sql, { + limit: `${lo},${hi}`, + nm: true, + }) + if (result.type === QuestDB.Type.DQL) { + rendererFn(result) + } + } catch (err) { + // Order of actions is important + dispatch( + actions.query.addNotification({ + query: `${sql}@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}`, + content: {(err as ErrorResult).error}, + sideContent: , + type: NotificationType.ERROR, + updateActiveNotification: true, + }), + ) + if (runningRef.current === RunningType.NONE) { dispatch(actions.query.stopRunning()) } - }, - ) - gridRef.current = _grid + } + }, + [], + ) + + useEffect(() => { + runningRef.current = running + }, [running]) + + useEffect(() => { + if (!useNewGrid) { + gridRef.current = grid(document.getElementById("grid"), paginationFn) + } + quickVis( $("#quick-vis"), window.bus as unknown as ReturnType, @@ -191,23 +225,26 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { questExecution, ) - _grid.addEventListener( - "selection.change", - function (event: CustomEvent<{ hasSelection: boolean }>) { - setGridHasSelection(event.detail.hasSelection) - }, - ) - - _grid.addEventListener("yield.focus", function () { - eventBus.publish(EventType.MSG_EDITOR_FOCUS) - }) - - _grid.addEventListener( - "freeze.state", - function (event: CustomEvent<{ freezeLeft: number }>) { - setGridFreezeLeftState(event.detail.freezeLeft) - }, - ) + const _grid = gridRef.current + if (_grid) { + _grid.addEventListener( + "selection.change", + function (event: CustomEvent<{ hasSelection: boolean }>) { + setGridHasSelection(event.detail.hasSelection) + }, + ) + + _grid.addEventListener("yield.focus", function () { + eventBus.publish(EventType.MSG_EDITOR_FOCUS) + }) + + _grid.addEventListener( + "freeze.state", + function (event: CustomEvent<{ freezeLeft: number }>) { + setGridFreezeLeftState(event.detail.freezeLeft) + }, + ) + } }, []) useEffect(() => { @@ -217,11 +254,26 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { } }, [result]) + // Benchmarking only: lets the harness seed either grid with a synthetic + // rows x cols result without a real query. Inert unless mock.pagination is on. + useEffect(() => { + if (!isMockPagination()) return + const benchSeed = (rows: number, cols: number) => { + const seed = seedMock(rows, cols) + setCount(seed.count) + gridRef.current?.setData(seed) + } + const target = window as unknown as { __benchSeed?: typeof benchSeed } + target.__benchSeed = benchSeed + return () => { + delete target.__benchSeed + } + }, []) + useEffect(() => { - const grid = document.getElementById("grid") const chart = document.getElementById("quick-vis") - if (!grid || !chart) { + if (!chart) { return } @@ -242,9 +294,10 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { const gridActions = [ { - tooltipText: "Copy result to Markdown", + tooltipText: "Copy current page to Markdown", trigger: (