feat: add full query text support to run with selection option - #604
feat: add full query text support to run with selection option#604emrberk wants to merge 12 commits into
Conversation
Review — PR #604
|
| Check | Result |
|---|---|
yarn typecheck |
pass |
yarn lint (eslint + color tokens) |
pass |
yarn test:unit |
pass — 89 files, 2025 tests |
yarn build |
pass |
Issues
| # | Issue | Category | Severity | Location | Net impact | Evidence |
|---|---|---|---|---|---|---|
| 1 | Notebook Cmd+Enter dead for wrapper focus | Accessibility & UX | Moderate | in-diff | Notebook users clicking anywhere non-interactive | N/A — static, Cell.tsx:385-388 + useCellWrapperInteractions.ts:95-96 |
| 2 | Run clears the selection in Complete mode | Accessibility & UX | Moderate | in-diff | Complete-mode users lose the selection every run | N/A — static, index.tsx:474-486 vs base |
| 3 | Downgrade reads every new setting value as off | Persistence & migrations | Moderate | in-diff | Users who roll a QuestDB server back after saving settings | N/A — static, utils.ts:32-39 vs base parseBoolean |
| 4 | Staleness guard written three times | Code structure, readability & types | Minor | in-diff | Maintainers of the repo's largest file | N/A — static, index.tsx:680-684 / 707-711 / 1708-1712 |
| 5 | New tests decouple the cursor from the selection | Test review & coverage | Minor | in-diff | Weaker regression protection than it appears | N/A — static, utils.test.ts:109, notebookUtils.test.ts:1361 |
#1 — Notebook Cmd+Enter is a no-op whenever focus is on the cell wrapper
Description
The cell's window keydown handler (Cell.tsx:373-392) used to run the cell for
any Cmd/Ctrl+Enter that reached it — that is, whenever the cell was focused and
focus was not inside Monaco, which has its own action. The new version handles
Shift+Enter, then handles the non-shift case only when focus is inside
resultRef. There is no else, so every other focus location is a silent
no-op, and preventDefault is not called either.
The gate misses the very case it was written for. useCellWrapperInteractions.ts:95-96
focuses wrapperRef on any mousedown whose target does not match
"button, a, input, select, textarea, [contenteditable], .monaco-editor", while
canArrowMove is true (layoutMode !== "grid" && !isMaximized, line 32).
wrapperRef is the ancestor of resultRef, so resultRef.contains(wrapper)
is false. Clicking non-interactive content inside the result area — the
status-notification text, the padding around the grid — therefore lands focus on
the wrapper and kills the shortcut.
Reachable focus states where base ran the cell and head does nothing:
- the cell wrapper, after clicking the result's notification text, the cell
padding, the drag header, or a divider; - a toolbar button, after clicking Run cell / a view toggle / refresh;
- the cell-name input;
document.body, in grid layout mode (canArrowMovefalse, so nothing takes
focus).
A view-maximized cell is the worst case. Cell.tsx:504 renders
EditorContainer only when !isViewMaximized, so in the grid-only view Monaco
is not mounted at all and its own notebook-run action does not exist. Every
Cmd+Enter in such a cell goes through this handler, and it fires only when focus
is literally inside the result DOM — which the wrapper-focus behaviour above
prevents for most clicks.
Cmd+Shift+Enter still works from every location, and the Monaco and result-tab
paths work, so the flow is recoverable — but the primary run shortcut silently
stops working with no toast and no other feedback.
Steps to reproduce
- Open a notebook cell with
select 1;and run it. - Click the result's status-notification text (or the padding beside the grid).
- Press Cmd/Ctrl+Enter.
- Nothing happens. At base the cursor's statement ran.
Suggested fix
Give the non-shift branch an explicit fallback so the resolver stays reachable,
keeping the intended result-area routing:
e.preventDefault()
if (resultRef.current?.contains(document.activeElement)) runSingleFromResult()
else runSingleFromEditor()Add runSingleFromEditor to the effect's dependency array. If the no-op is
deliberate, the wrapper-focus case still needs handling, because clicking inside
the result slot does not put focus inside resultRef.
#2 — Running in Complete mode clears the user's selection
Description
handlePrimaryRun resolves a Complete-mode selection into a whole-statement
Request with no selection field. runQueryAction then calls
setCursorBeforeRunning, whose new guard (index.tsx:474-478) takes the "leave
the cursor alone" fast path only when the mode is off or the selection is
empty. In Complete mode with a live selection it falls through to
editor.setPosition(...) (index.tsx:484), which collapses the selection to the
statement start.
The correct SQL still runs — the request is captured in pendingQueryRequestRef
before the cursor moves — so this is not a data problem. The cost is
interaction: in Complete mode the selection the user made to express intent is
destroyed by every single-statement run, so re-running the same fragment needs a
fresh selection, and the run button label flips back to "Run query". Partial mode
keeps the selection (its requests carry selection, so the range is re-applied),
so the two "on" modes behave inconsistently.
The same guard also changes base behaviour in the default Partial mode: running a
whole statement from the glyph dropdown while a fragment is selected used to hit
the fast path (the cursor query and the glyph query produce the same key) and
preserve the selection; it now collapses it.
There is no regression test for selection preservation on any run path —
grep -rn "getSelection()\|preserveSelection" e2e/tests/console/editor.spec.js
returns notebook-only hits — even though commit cda7c901 ("preserve selection
on query run in off mode") treats it as a fix.
Steps to reproduce
- Editor Settings → Run with selection → Complete queries → Save.
- Type
select a from trades where symbol = 'X'. - Select only the table name
trades. - Press Cmd/Ctrl+Enter, or click Run query.
- The whole statement runs, but the
tradesselection is gone and the caret sits
at the start of the statement.
Partial-mode (base regression) variant:
- Leave the mode at the default Partial queries.
- Type
select a from trades; select 2;and selecttrades. - Click the run glyph on line 1 and choose the whole-query entry.
- At base the
tradesselection stayed; on this branch it is gone.
Suggested fix
The cursor move is no longer needed for correctness on these paths —
pendingQueryRequestRef carries the exact request, and getQueryStartOffset
derives its offset from the request, not the cursor. Pass preserveSelection
through when the run came from a resolved selection:
if (queries.length === 1) {
handleRunQuery(queries[0], resolvesSelection)
}That leaves off-mode and no-selection behaviour untouched and lets the extra
mode condition come back out of setCursorBeforeRunning. Add an e2e assertion
that the selection survives a run in each mode.
#3 — After this ships, an older console reads every stored value as "off"
Description
The setting keeps its localStorage key editor.runWithSelection
(StoreKey.RUN_WITH_SELECTION) while its value domain changes from
"true"/"false" to "partial"/"complete"/"off". The upgrade direction is
handled — parseRunWithSelectionMode (utils.ts:32-39) maps "false" → off
and everything else, including the legacy "true", to partial, which I checked
is behaviour-preserving against the base resolver.
The downgrade direction is not. Older code reads the key through
parseBoolean(value, true) = value === "true", so all three new values —
including "partial", which is exactly the old ON default — read as false. A
user who never turned the feature off finds it silently off, and pressing Save on
the old build then persists "false", so re-upgrading lands on off
permanently.
This is reachable because console versions are pinned to server versions:
.github/workflows/release_web_console.yml publishes @questdb/web-console to
npm, then its update-questdb job writes that version into questdb/questdb's
pom.xml. One console bundle ships per server binary, served from the same
origin against the same localStorage, so a server rollback serves the older
console. The producer needs nothing unusual: EditorSettingsModal:167 writes the
key unconditionally on Save, even when the user only edited Max column width.
No data is lost and the direction is conservative (the editor runs the cursor
statement rather than more SQL), and it is two clicks to fix, which is why this is
not Critical.
Steps to reproduce
- On a build with this PR, open Editor Settings, change anything, press Save
(editor.runWithSelectionis now"partial"). - Roll the QuestDB server back to a version whose embedded console predates this
PR, and open the console on the same host and port. - Editor Settings shows Run with selection off; selections are ignored.
Suggested fix
Either write the enum to a new key (editor.runWithSelectionMode) and keep
reading the old one as the legacy fallback, or keep a legacy-compatible mirror —
write "true" to editor.runWithSelection for partial/complete and
"false" for off alongside the enum — so an older reader degrades to the
nearest old behaviour instead of always to off.
#4 — The run-plan staleness guard is written three times
Description
The same three-condition check — active buffer id, model identity, model version
id — appears verbatim at index.tsx:680-684 (runQueryAction's execute),
707-711 (executePendingScriptRun) and 1708-1712 (handleRunScript), each
followed by a toast.error whose two message variants differ only in
"query"/"queries". createScriptRunPlan already exists to build the captured
plan; the matching predicate does not. A fourth run entry point will silently
skip the guard, and this is the largest file in the repo.
Steps to reproduce
grep -n "modelVersionId" src/scenes/Editor/Monaco/index.tsx
Suggested fix
Add a companion to createScriptRunPlan and call it at all three sites:
const isRunPlanStale = (plan: Pick<ScriptRunPlan, "bufferId" | "model" | "modelVersionId">) =>
activeBufferRef.current.id !== plan.bufferId ||
editorRef.current?.getModel() !== plan.model ||
plan.model.getVersionId() !== plan.modelVersionIdrunQueryAction can build the same shape for a single query instead of keeping
three loose locals.
#5 — The new unit tests model states the code under test cannot produce
Description
Two of the test helpers this PR adds weaken the assertions built on them:
makeMultiLineEditor(utils.test.ts:109) hardcodes
getPosition: () => getPositionAt(text.length)— the cursor is always at the
end of the document, independent of the selection. In Monaco the cursor is one
end of the selection. Everyoff-mode assertion in a multi-line test therefore
resolves to the last statement regardless of where the selection sits, so those
cases pin a state a real editor cannot reach and do not protect the behaviour
they name.makeSingleLineEditortakescursorColumnas an independent
argument with the same effect.heightForResult(notebookUtils.test.ts:1361) synthesizes the newvalue
argument from the results themselves, guaranteeing a 1:1 statement frame. The
whole point of the new parameter is thatvalueandresultscan diverge, so
the nine migrated assertions exercise none of it. Only the three
hand-written-valuetests cover the new behaviour, and none reaches the
?? derivePositionalFrameorif (!frame)fallbacks.
The offset and range arithmetic in both stubs is faithful — this is a cursor and
input-fidelity problem, not a broken harness — and the e2e suite covers off-mode
behaviour against real Monaco, which is why this is Minor.
Steps to reproduce
- Read
src/scenes/Editor/Monaco/utils.test.ts:100-127and
src/scenes/Editor/Notebook/notebookUtils.test.ts:1361-1366.
Suggested fix
Derive getPosition() from the selection's end in both stubs (and let a test opt
into a backward selection explicitly), and drop heightForResult in favour of
passing the cell value each assertion actually means.
Adjacent findings (not blocking — file as issues)
A multi-statement selection run skips the destructive-action confirmation
- Problem: Selection script runs bypass the "may delete data" dialog
- Net impact: All console users; selected DDL/DML runs with no confirmation
- Location:
src/scenes/Editor/Monaco/index.tsx:1636-1647(base:1553-1566) - Symptom: Selecting several statements that include
DROP/DELETE/
TRUNCATEand pressing Cmd+Enter executes them immediately. "Run all queries"
shows a confirmation for the same SQL; a selection run does not. The dialog
appears on this path only when another execution is already active, and then
only to warn that it will be aborted. - Reachability: Idle editor, selection spanning more than one statement,
Cmd+Enter or the Run selected queries button. - Suggested fix: Show the same confirmation when a selection resolves to more
than one statement, or at least when any resolved statement classifies as a
write. - Severity if filed standalone: Moderate
- Note: identical at base (
runsAllQuerieswasfalsefor a multi-query
selection there too), so it is not attributed to this PR. Worth filing because
this PR widens what a selection reaches, in two ways: Complete mode expands a
one-character overlap into the whole statement (I reproduced
COMPLETE ["SELECT 1","DROP TABLE t"]from a selection touching one character
ofDROP), andhandlePrimaryRunnow resolves from a full-document parse
instead of the viewport-windowed offsets, so Cmd+A in a long buffer runs every
statement rather than the visible window. Both are correct on their own terms —
the second fixes a real base bug — but they land on a path with no confirmation.
Summary
Verdict: approve with comments.
Address #1 and #2 before merge if you agree they are unintended; neither blocks.
#3 is worth a decision now because it is only cheap to fix before the enum ships.
- Correctness gate: passes. No admitted Critical. The change is largely a set
of corrections to previously broken selection handling, and the base
comparisons back that up: a comment-only selection used to invert the
first/last offset lookup and could target a neighbouring statement (now[]);
handleRunScriptused to fall back to run-all when the selection ref held one
entry (now an explicit plan); a selection run started during a script used to be
discarded (now queued behind a confirmation, with a new test); Alt+L used to
copy syntactically broken SQL such asSELECT;(now the whole statement); a
share link whose text matched mid-statement used to select and run that
fragment (now it opens its own buffer); and Cmd+Enter used to resolve against
viewport-windowed offsets, so a selection past the window ran the wrong set. - Test gate: passes. Zero admitted Critical coverage gaps. Coverage for the
new pure logic is real and the e2e suite exercises all three modes in the editor
and in notebooks, the legacy"false"migration on boot, the queued selection
run, comment-only and separator-only selections, and share-link fragment
isolation. Two Moderate gaps are noted inside findings rather than as separate
rows: no regression test for selection preservation (init react components package #2) and no test for the
newvalueargument's divergent case (Console autocomplete tests #5). The three staleness guards are
untested but unreachable from any production path traced, so they are defensive
rather than functional. - In-diff / out-of-diff split: 5 in-diff, 0 out-of-diff. The 2.5d exposure
list was walked callsite by callsite. The consumers that could have broken did
not:getDropdownQueries,applyLineMarkings,createQueryKeyFromRequest,
getQueryStartOffset, the error-marker offset shift,extractErrorByQueryKey
andQueryDropdown.isExplainDisabledall tolerateRequest.selectionbeing
absent in Complete mode;ButtonBarhandles both the emptyqueriesToRunand
the missingselection; all sixcomputeResultBottomHeightcallsites pass a
valuefrom the same store snapshot the renderer reads; and the Run button now
joins the managed execution path it previously bypassed, which
queryExecutionManager's own comment names as the intent. The change is
genuinely well contained. - Severity distribution: 0 Critical, 3 Moderate, 2 Minor.
- Migration: the
"true"→"partial"upgrade is behaviour-preserving. Base
runWithSelection === trueand head"partial"differ only where the two new
guards fire (whitespace/comment-only selections and the inverted-offset case),
and base already returned[]for the first and mis-targeted a statement for
the second. The e2e comment asserting this is correct. The downgrade direction
is disabled buttons #3. - Not a finding, but sanity-check it — the notebook "Nothing to run" toast.
handleRunSingle("editor")dropped both of its fallbacks
(useCellRunActions.ts:186-194): base did
sql = cursorQuery ?? resolveActiveStatementSql(...)and then
if (!sql?.trim()) await handleRunAll(). This is a deliberate, tested
decision — the e2e test "never widens notebook Cmd+Enter beyond its focused
query" asserts the toast, and one of its steps is commented "the same active
tab never becomes a fallback for Monaco" — so it is not reported as a defect.
Worth a second look anyway, because the tests cover only a comment-line
cursor, and I confirmedgetQueryFromCursoralso returnsundefinedfor the
trailing blank line after a semicolon-terminated statement: type
select 1;, press Enter, press Cmd+Enter, and a single-statement cell now
errors where base ran it. The "never widens" rationale does not apply there —
handleRunAll()would have run exactly the one statement the user wrote.
("select 1\n"without the semicolon still resolves, so only the terminated
form is affected.) - Not a finding — checked and dropped: the
e2e/questdbsubmodule bump
(9b59a921 → e40ec59d, 15 commits) is required, not scope creep:
.github/workflows/check_oss_submodule.ymlruns on every push and
submodule_is_mergeable.pyfails the build when the pointer is more than
ACCEPTABLE_LAG = 5commits behind questdb master. The old pointer was 15
behind. Two of those commits do change SQL semantics
(fix(sql): … HORIZON JOIN, WINDOW JOIN, LATEST ON …,perf(sql): stream ordered UNION ALL without sorting) andunion allappears in this file's
share-link test, so an e2e failure here is not attributable to the UI diff
alone — but that is the repo's standing policy, not this PR's choice.
Summary
Adds a new option
Complete queriesto run full query texts in the selected range.Example
we have
and the selected portion is:
Before
select 2, runs.core_price where symbol = 'EURUSD'andselect 2.Now
select amount from core_price where symbol = 'EURUSD'andselect 2runs