diff --git a/e2e/commands.js b/e2e/commands.js
index b02401e76..905bbf2bf 100644
--- a/e2e/commands.js
+++ b/e2e/commands.js
@@ -264,6 +264,80 @@ Cypress.Commands.add("typeQueryDirectly", (query) => {
})
})
+Cypress.Commands.add("waitForActiveBufferValue", (expectedValue) => {
+ cy.window().then((win) => {
+ const readActiveBufferValue = () =>
+ new Cypress.Promise((resolve, reject) => {
+ const openRequest = win.indexedDB.open("web-console")
+ openRequest.onerror = () => reject(openRequest.error)
+ openRequest.onblocked = () =>
+ reject(new Error("web-console IndexedDB open is blocked"))
+ openRequest.onsuccess = () => {
+ const database = openRequest.result
+ const fail = (error) => {
+ database.close()
+ reject(error)
+ }
+ try {
+ const transaction = database.transaction(
+ ["editor_settings", "buffers"],
+ "readonly",
+ )
+ transaction.onerror = () => fail(transaction.error)
+ const activeBufferRequest = transaction
+ .objectStore("editor_settings")
+ .index("key")
+ .get("activeBufferId")
+
+ activeBufferRequest.onerror = () => fail(activeBufferRequest.error)
+ activeBufferRequest.onsuccess = () => {
+ try {
+ const activeBufferId = activeBufferRequest.result?.value
+ if (activeBufferId === undefined) {
+ fail(new Error("no activeBufferId row in editor_settings"))
+ return
+ }
+ const bufferRequest = transaction
+ .objectStore("buffers")
+ .get(activeBufferId)
+ bufferRequest.onerror = () => fail(bufferRequest.error)
+ bufferRequest.onsuccess = () => {
+ const value = bufferRequest.result?.value
+ database.close()
+ resolve(value)
+ }
+ } catch (error) {
+ fail(error)
+ }
+ }
+ } catch (error) {
+ fail(error)
+ }
+ }
+ })
+
+ // Poll well inside Cypress's own command timeout so this command's
+ // message wins over a generic cy.then() timeout.
+ const deadline = Date.now() + 8000
+ const poll = () =>
+ readActiveBufferValue().then((value) => {
+ if (value === expectedValue) return
+ if (Date.now() >= deadline) {
+ throw new Error(
+ `Active buffer did not persist ${JSON.stringify(
+ expectedValue,
+ )} — last read ${JSON.stringify(value)}`,
+ )
+ }
+ return new Cypress.Promise((resolve) =>
+ win.setTimeout(resolve, 50),
+ ).then(poll)
+ })
+
+ return poll()
+ })
+})
+
Cypress.Commands.add("runLine", () => {
cy.intercept("/exec*").as("exec")
cy.typeQuery(`${ctrlOrCmd}{enter}`)
@@ -443,6 +517,36 @@ Cypress.Commands.add("selectRange", (startPos, endPos) => {
})
})
+Cypress.Commands.add("createNotebook", () => {
+ cy.get(".chrome-tabs .new-tab-button").click()
+ cy.getByDataHook("new-tab-notebook").click()
+ cy.getByDataHook("notebook-toolbar").should("be.visible")
+ cy.getByDataHook("cell-editor-shimmer").should("not.exist")
+ cy.getByDataHook("cell-grid-shimmer").should("not.exist")
+ cy.get("[data-notebook-cell] .monaco-editor textarea").should("exist")
+})
+
+Cypress.Commands.add("focusNotebookCell", () => {
+ cy.get("[data-notebook-cell] .monaco-editor .view-lines").first().click()
+ cy.focused().should("have.class", "inputarea")
+})
+
+Cypress.Commands.add("withFocusedEditor", (fn) => {
+ cy.window().should((win) => {
+ const hasFocusedEditor = win.monaco.editor
+ .getEditors()
+ .some((candidate) => candidate.hasTextFocus())
+ expect(hasFocusedEditor, "a Monaco editor has focus").to.eq(true)
+ })
+ cy.window().then((win) =>
+ fn(
+ win.monaco.editor
+ .getEditors()
+ .find((candidate) => candidate.hasTextFocus()),
+ ),
+ )
+})
+
Cypress.Commands.add("getVisibleLines", () => cy.get(".view-lines"))
Cypress.Commands.add("expandNotifications", () =>
diff --git a/e2e/questdb b/e2e/questdb
index 9b59a9211..b4afea3ea 160000
--- a/e2e/questdb
+++ b/e2e/questdb
@@ -1 +1 @@
-Subproject commit 9b59a921165af573cedd22bf8b12613de19cb8bd
+Subproject commit b4afea3eaf80630654a5f6ff178a71692e18c41c
diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js
index 114d3f92d..c607ed4aa 100644
--- a/e2e/tests/console/editor.spec.js
+++ b/e2e/tests/console/editor.spec.js
@@ -186,7 +186,7 @@ describe("run query with selection", () => {
// Then
cy.getByDataHook("button-run-query").should(
"contain",
- "Run 2 selected queries",
+ "Run selected queries",
)
// When
@@ -344,6 +344,43 @@ describe("run query with selection", () => {
.should("match", /Running completed in .+ with\s+2 successful\s+queries/)
})
+ it("should queue selected queries when starting a run while a script is busy", () => {
+ // Given a script is running
+ cy.intercept("/exec*", (req) => {
+ req.on("response", (res) => {
+ res.setDelay(1000)
+ })
+ })
+ cy.typeQuery("select 1;\nselect 2;\nselect 3;")
+ cy.clickRunScript()
+ cy.getByDataHook("loading-notification").should(
+ "contain",
+ 'Running query "select 1"',
+ )
+
+ // When a strict subset is selected and run with the primary shortcut
+ cy.selectQueries({
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 10,
+ })
+ cy.focused().type(`${ctrlOrCmd}{enter}`)
+
+ // Then the selected run is queued for confirmation instead of discarded
+ cy.getByRole("dialog").should("be.visible")
+ cy.getByRole("dialog").should("contain", "Run selected queries")
+ cy.getByRole("dialog").should("contain", "2 selected queries")
+
+ // When confirmed, the active script is cancelled and the selection runs
+ cy.getByDataHook("run-all-queries-confirm").click()
+ cy.contains('[data-hook="success-notification"]', "Running completed", {
+ timeout: 20000,
+ })
+ .invoke("text")
+ .should("match", /Running completed in .+ with\s+2 successful\s+queries/)
+ })
+
it("should run all queries when starting a run-all while a query is busy", () => {
// Given a single query is running
cy.intercept("/exec*", (req) => {
@@ -402,7 +439,7 @@ describe("run all queries in tab", () => {
// Then
cy.getByDataHook("button-run-query").should(
"contain",
- "Run 6 selected queries",
+ "Run selected queries",
)
// When
@@ -994,28 +1031,36 @@ describe("&query URL param", () => {
params.set("executeQuery", "true")
return `${baseUrl}/?${params.toString()}`
}
+ const expectClipboardWrite = (callCount, sql) => {
+ cy.get("@clipboardWrite").should((writeText) => {
+ expect(writeText).to.have.callCount(callCount)
+ expect(writeText.lastCall.args).to.deep.eq([expectedUrl(sql)])
+ })
+ }
+ cy.window().then((win) => {
+ cy.stub(win.navigator.clipboard, "writeText")
+ .as("clipboardWrite")
+ .resolves()
+ })
// When — glyph dropdown copies single full query
cy.openRunDropdownInLine(1)
cy.getByDataHook("dropdown-item-copy-query-link").click()
// Then
- cy.window()
- .its("navigator.clipboard")
- .then((clip) => clip.readText())
- .should("eq", expectedUrl("SELECT 1;"))
+ expectClipboardWrite(1, "SELECT 1;")
- // When — glyph dropdown copies a selection inside a single query
+ // When — glyph dropdown copies the complete query even with a
+ // fragment selected inside it
cy.selectRange({ lineNumber: 2, column: 1 }, { lineNumber: 2, column: 7 })
cy.getByDataHook("button-run-query").should("contain", "Run selected query")
cy.openRunDropdownInLine(2)
- cy.getByDataHook("dropdown-item-copy-query-link").click()
+ cy.getByDataHook("dropdown-item-copy-query-link")
+ .should("contain", 'Copy link to "SELECT 2"')
+ .click()
// Then
- cy.window()
- .its("navigator.clipboard")
- .then((clip) => clip.readText())
- .should("eq", expectedUrl("SELECT;"))
+ expectClipboardWrite(2, "SELECT 2;")
// When — Alt+L copies single query at cursor
cy.clickLine(3)
@@ -1023,33 +1068,24 @@ describe("&query URL param", () => {
cy.realPress(["Alt", "L"])
// Then
- cy.window()
- .its("navigator.clipboard")
- .then((clip) => clip.readText())
- .should("eq", expectedUrl("SELECT 3;"))
+ expectClipboardWrite(3, "SELECT 3;")
// When — Alt+L copies selection spanning multiple queries
cy.selectRange({ lineNumber: 1, column: 1 }, { lineNumber: 2, column: 9 })
cy.getByDataHook("button-run-query").should(
"contain",
- "Run 2 selected queries",
+ "Run selected queries",
)
cy.realPress(["Alt", "L"])
// Then
- cy.window()
- .its("navigator.clipboard")
- .then((clip) => clip.readText())
- .should("eq", expectedUrl("SELECT 1;\n\nSELECT 2;"))
+ expectClipboardWrite(4, "SELECT 1;\n\nSELECT 2;")
// When — Alt+Shift+L copies all queries in tab
cy.realPress(["Alt", "Shift", "L"])
// Then
- cy.window()
- .its("navigator.clipboard")
- .then((clip) => clip.readText())
- .should("eq", expectedUrl("SELECT 1;\n\nSELECT 2;\n\nSELECT 3;"))
+ expectClipboardWrite(5, "SELECT 1;\n\nSELECT 2;\n\nSELECT 3;")
})
})
@@ -2127,17 +2163,17 @@ describe("editor settings", () => {
cy.getByDataHook("editor-settings-modal").should("be.visible")
}
- it("toggles 'Run with selection' from the modal and persists it", () => {
- // Given the setting defaults to on
+ it("changes 'Run with selection' from the modal and persists it", () => {
+ // Given the setting defaults to partial
openEditorSettings()
cy.getByDataHook("editor-settings-run-with-selection").should(
- "have.attr",
- "aria-checked",
- "true",
+ "contain",
+ "Partial queries",
)
- // When it is switched off and saved
+ // When it is set to off and saved
cy.getByDataHook("editor-settings-run-with-selection").click()
+ cy.getByDataHook("run-with-selection-off").click()
cy.getByDataHook("editor-settings-save").click()
cy.getByDataHook("editor-settings-modal").should("not.exist")
@@ -2145,66 +2181,475 @@ describe("editor settings", () => {
cy.window()
.its("localStorage")
.invoke("getItem", "editor.runWithSelection")
- .should("eq", "false")
+ .should("eq", "off")
// And reopening the modal shows it still off
openEditorSettings()
cy.getByDataHook("editor-settings-run-with-selection").should(
- "have.attr",
- "aria-checked",
- "false",
+ "contain",
+ "Off",
+ )
+ cy.getByDataHook("editor-settings-cancel").click()
+ cy.getByDataHook("editor-settings-modal").should("not.exist")
+ })
+
+ // Only the legacy "false" is asserted here: "true" migrates to the same
+ // "partial" the default already produces, so seeding it could not fail.
+ it("migrates a legacy disabled setting to Off on boot", () => {
+ // Given a console booting with the boolean a previous version persisted
+ // under the same key the mode enum now uses
+ cy.loadConsoleWithAuth(false, { "editor.runWithSelection": "false" })
+ cy.getEditorContent().should("be.visible")
+
+ // When the settings modal is opened
+ openEditorSettings()
+
+ // Then the legacy value migrated to Off rather than the partial default
+ cy.getByDataHook("editor-settings-run-with-selection").should(
+ "contain",
+ "Off",
)
cy.getByDataHook("editor-settings-cancel").click()
cy.getByDataHook("editor-settings-modal").should("not.exist")
+
+ // And the selection is genuinely ignored, not merely labelled Off
+ const query = `select a from ${runWithSelectionTable}`
+ const startColumn = query.indexOf(runWithSelectionTable) + 1
+ cy.typeQueryDirectly(query)
+ cy.selectRange(
+ { lineNumber: 1, column: startColumn },
+ { lineNumber: 1, column: startColumn + runWithSelectionTable.length },
+ )
+ cy.getByDataHook("button-run-query").should("contain", "Run query")
+ cy.clickRunQuery()
+ cy.get("[data-hook='grid-header-name']").should("have.length", 1)
})
- it("runs the selected fragment when enabled and the whole query once disabled", () => {
+ it("resolves the selection per mode: partial fragment, complete query, ignored when off", () => {
const table = runWithSelectionTable
const query = `select a from ${table}`
const startColumn = query.indexOf(table) + 1
const endColumn = startColumn + table.length
+ const selectTableName = () =>
+ cy.selectRange(
+ { lineNumber: 1, column: startColumn },
+ { lineNumber: 1, column: endColumn },
+ )
+ const setMode = (mode) => {
+ openEditorSettings()
+ cy.getByDataHook("editor-settings-run-with-selection").click()
+ cy.getByDataHook(`run-with-selection-${mode}`).click()
+ cy.getByDataHook("editor-settings-save").click()
+ }
// Given a query selecting one column, with the table name highlighted
cy.typeQueryDirectly(query)
- cy.selectRange(
- { lineNumber: 1, column: startColumn },
- { lineNumber: 1, column: endColumn },
- )
+ selectTableName()
- // When 'Run with selection' is enabled (default), the selection runs
+ // When the mode is partial (default), the selection runs as-is
cy.getByDataHook("button-run-query").should("contain", "Run selected query")
cy.clickRunQuery()
// Then the bare table name returns every column
cy.get("[data-hook='grid-header-name']").should("have.length", 3)
- // When 'Run with selection' is turned off
- openEditorSettings()
- cy.getByDataHook("editor-settings-run-with-selection").click()
- cy.getByDataHook("editor-settings-save").click()
+ // When the mode is set to complete
+ setMode("complete")
// Then the run button reflects the new setting without any further
// editor interaction
cy.getByDataHook("button-run-query").should("contain", "Run query")
+ // And the same fragment expands to the whole query, returning one column
+ selectTableName()
+ cy.getByDataHook("button-run-query").should("contain", "Run query")
+ cy.clickRunQuery()
+ cy.get("[data-hook='grid-header-name']").should("have.length", 1)
+
+ // When the mode is set to off
+ setMode("off")
+
// And the same fragment is selected and run again
- cy.selectRange(
- { lineNumber: 1, column: startColumn },
- { lineNumber: 1, column: endColumn },
- )
+ selectTableName()
cy.getByDataHook("button-run-query").should("contain", "Run query")
cy.clickRunQuery()
// Then the whole cursor query runs, returning only the single column
cy.get("[data-hook='grid-header-name']").should("have.length", 1)
})
- it("runs the share-link selection even when 'Run with selection' is off", () => {
+ it("expands a cross-statement selection to both whole queries in complete mode", () => {
+ // Given the mode is set to complete
+ openEditorSettings()
+ cy.getByDataHook("editor-settings-run-with-selection").click()
+ cy.getByDataHook("run-with-selection-complete").click()
+ cy.getByDataHook("editor-settings-save").click()
+
+ // And two statements with a selection cutting into both
+ cy.typeQueryDirectly("select 1; select 2;")
+ cy.selectRange({ lineNumber: 1, column: 8 }, { lineNumber: 1, column: 14 })
+
+ // When both expanded queries run
+ cy.getByDataHook("button-run-query").should(
+ "contain",
+ "Run selected queries",
+ )
+ cy.clickRunQuery()
+
+ // Then the second whole query ran last, so its result shows
+ cy.getGridRows().should("have.length", 1)
+ cy.getGridRow(0).should("contain", "2")
+ })
+
+ it("runs the glyph's own query in complete mode while a selection spans both statements", () => {
+ // Given the mode is set to complete
+ openEditorSettings()
+ cy.getByDataHook("editor-settings-run-with-selection").click()
+ cy.getByDataHook("run-with-selection-complete").click()
+ cy.getByDataHook("editor-settings-save").click()
+
+ const execQueries = []
+ const statements = new Set(["select 1", "select 2"])
+ cy.intercept({ url: "/exec*" }, (request) => {
+ const query = new URL(request.url).searchParams.get("query")
+ if (statements.has(query)) execQueries.push(query)
+ request.continue()
+ })
+
+ // And two statements with a selection whose caret ends inside the second
+ cy.typeQueryDirectly("select 1;\nselect 2;")
+ cy.selectRange({ lineNumber: 1, column: 8 }, { lineNumber: 2, column: 9 })
+ cy.getByDataHook("button-run-query").should(
+ "contain",
+ "Run selected queries",
+ )
+
+ // When the second statement's run glyph is clicked
+ cy.getRunIconInLine(2).click({ force: true })
+
+ // Then that statement runs, never the first one the selection reaches back into
+ cy.wrap(null).should(() => {
+ expect(execQueries).to.deep.eq(["select 2"])
+ })
+ cy.getGridRow(0).should("contain", "2")
+ })
+
+ it("disables run while the selection covers only a comment between statements", () => {
+ // Given a commented-out statement between two live statements
+ cy.typeQueryDirectly("select 1;\n-- old: delete from t\nselect 2;")
+
+ // When a word inside the comment is selected
+ cy.selectRange({ lineNumber: 2, column: 9 }, { lineNumber: 2, column: 15 })
+
+ // Then the run button disables instead of targeting a neighbouring
+ // statement
+ cy.getByDataHook("button-run-query").should("be.disabled")
+
+ // And the keyboard shortcut is also a no-op
+ cy.focused().type(`${ctrlOrCmd}{enter}`)
+ cy.getByDataHook("success-notification").should("not.exist")
+
+ // And moving the cursor back into a statement enables it again
+ cy.clickLine(1)
+ cy.getByDataHook("button-run-query").should("not.be.disabled")
+ })
+
+ it("disables run while the selection covers only a statement separator", () => {
+ // Given two statements, the second of which drops a table
+ cy.typeQueryDirectly("select 1;\ndrop table if exists t;")
+
+ // When only the first statement's terminating semicolon and the newline
+ // are selected, leaving the caret inside the second statement
+ cy.selectRange({ lineNumber: 1, column: 9 }, { lineNumber: 2, column: 1 })
+
+ // Then the run button disables rather than targeting the statement the
+ // caret happens to sit in
+ cy.getByDataHook("button-run-query").should("be.disabled")
+
+ // And the keyboard shortcut runs nothing either
+ cy.focused().type(`${ctrlOrCmd}{enter}`)
+ cy.getByDataHook("success-notification").should("not.exist")
+ })
+
+ it("resolves the notebook cell selection per mode: partial, complete, off", () => {
+ const table = runWithSelectionTable
+ const sql = `select a from ${table}; select 33`
+ const tableStart = sql.indexOf(table) + 1
+ const tableEnd = tableStart + table.length
+ // Reaches into the second statement's "sele", cutting both statements.
+ const crossEnd = sql.indexOf("; select") + 8
+
+ const setMode = (mode) => {
+ openEditorSettings()
+ cy.getByDataHook("editor-settings-run-with-selection").click()
+ cy.getByDataHook(`run-with-selection-${mode}`).click()
+ cy.getByDataHook("editor-settings-save").click()
+ cy.getByDataHook("editor-settings-modal").should("not.exist")
+ }
+ const selectCellRange = (startColumn, endColumn) =>
+ cy.withFocusedEditor((editor) =>
+ editor.setSelection({
+ startLineNumber: 1,
+ startColumn,
+ endLineNumber: 1,
+ endColumn,
+ }),
+ )
+ const runCellAtCursor = () =>
+ cy.withFocusedEditor((editor) => editor.getAction("notebook-run").run())
+ const cellResultTabs = () => cy.get("[role='tablist'] [role='tab']")
+
+ // And a resolved notebook whose focused cell holds two statements
+ cy.createNotebook()
+ cy.focusNotebookCell()
+ cy.focused().type(sql, { delay: 0 })
+ cy.withFocusedEditor((editor) => {
+ expect(editor.getValue()).to.eq(sql)
+ })
+
+ // Given the first cell exec request is held until the loader is observed
+ let releaseFirstExec
+ cy.intercept(
+ { url: "/exec*", times: 1 },
+ () =>
+ new Cypress.Promise((resolve) => {
+ releaseFirstExec = resolve
+ }),
+ ).as("heldExec")
+
+ // When the mode is partial (default) and the table name is selected
+ selectCellRange(tableStart, tableEnd)
+ runCellAtCursor()
+
+ // Then the table toggle spins while the fragment runs
+ cy.get("[aria-label='View table'][aria-busy='true']").should("exist")
+ cy.wrap(null).should(() => {
+ expect(releaseFirstExec).to.be.a("function")
+ })
+ cy.then(() => releaseFirstExec())
+ cy.wait("@heldExec")
+ cy.get("[aria-label='View table'][aria-busy='true']").should("not.exist")
+
+ // And only the bare fragment ran: a single result with every column
+ cy.get("[data-hook='grid-header-name']:visible").should("have.length", 3)
+ cellResultTabs().should("not.exist")
+ cy.get(".selectionSuccessHighlight").should("exist")
+
+ // When the mode is complete and the selection cuts into both statements
+ setMode("complete")
+ cy.focusNotebookCell()
+ selectCellRange(tableStart, crossEnd)
+ runCellAtCursor()
+
+ // Then both whole statements run to completion, first statement active
+ cy.get("[role='tablist'] [data-hook='result-tab-success']").should(
+ "have.length",
+ 2,
+ )
+ cy.getByDataHook("result-tab-loading").should("not.exist")
+ cellResultTabs()
+ .eq(0)
+ .should("have.attr", "title", `select a from ${table}`)
+ .and("have.attr", "aria-selected", "true")
+ cellResultTabs().eq(1).should("have.attr", "title", "select 33")
+ cy.get(".selectionSuccessHighlight, .selectionErrorHighlight").should(
+ "not.exist",
+ )
+
+ // When the mode is off and the same selection is made
+ setMode("off")
+ cy.focusNotebookCell()
+ selectCellRange(tableStart, crossEnd)
+ runCellAtCursor()
+
+ // Then only the statement at the cursor runs, ignoring the selection
+ cy.get("[role='tablist'] [data-hook='result-tab-success']").should(
+ "have.length",
+ 1,
+ )
+ cy.get("[role='tablist'] [data-hook='result-tab-not-run']").should(
+ "have.length",
+ 1,
+ )
+ cy.get("[role='tablist'] [role='tab'][aria-selected='true']").should(
+ "have.attr",
+ "title",
+ "select 33",
+ )
+ cy.get("[data-hook='grid-header-name']:visible")
+ .should("have.length", 1)
+ .and("contain", "33")
+ })
+
+ it("runs the whole notebook cell from Run cell and Run All regardless of selection", () => {
+ // Given a cell with two statements and a comment-only selection
+ cy.createNotebook()
+ cy.focusNotebookCell()
+ cy.focused().type("select 1;\n-- note\nselect 2;", { delay: 0 })
+ cy.withFocusedEditor((editor) =>
+ editor.setSelection({
+ startLineNumber: 2,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 8,
+ }),
+ )
+
+ const execQueries = []
+ const cellQueries = new Set(["select 1", "select 2"])
+ cy.intercept({ url: "/exec*" }, (request) => {
+ const query = new URL(request.url).searchParams.get("query")
+ if (cellQueries.has(query)) execQueries.push(query)
+ request.continue()
+ })
+
+ // When Run cell is clicked, the selection does not narrow its scope
+ cy.get("[data-notebook-cell] button[aria-label='Run cell']")
+ .should("contain", "Run")
+ .and("have.attr", "aria-disabled", "false")
+ .click()
+
+ // Then every statement runs
+ cy.get("[role='tablist'] [data-hook='result-tab-success']").should(
+ "have.length",
+ 2,
+ )
+ cy.wrap(null).should(() => {
+ expect([...execQueries].sort()).to.deep.eq(["select 1", "select 2"])
+ })
+
+ // And Cmd+Shift+Enter runs the whole cell even from the result area
+ cy.get("[role='tablist'] [role='tab']").eq(1).click()
+ cy.get("[role='tablist'] [role='tab']").eq(1).should("have.focus")
+ cy.then(() => {
+ execQueries.length = 0
+ })
+ cy.window().then((win) => {
+ win.dispatchEvent(
+ new win.KeyboardEvent("keydown", {
+ key: "Enter",
+ shiftKey: true,
+ metaKey: Cypress.platform === "darwin",
+ ctrlKey: Cypress.platform !== "darwin",
+ bubbles: true,
+ }),
+ )
+ })
+ cy.wrap(null).should(() => {
+ expect([...execQueries].sort()).to.deep.eq(["select 1", "select 2"])
+ })
+ })
+
+ it("never widens notebook Cmd+Enter beyond its focused query", () => {
+ // Given a fresh cell whose cursor is in the gap between two statements
+ cy.createNotebook()
+ cy.focusNotebookCell()
+ cy.focused().type("select 1;\n-- note\nselect 2;", { delay: 0 })
+ cy.withFocusedEditor((editor) =>
+ editor.setPosition({ lineNumber: 2, column: 4 }),
+ )
+
+ const execQueries = []
+ const cellQueries = new Set(["select 1", "select 2"])
+ cy.intercept({ url: "/exec*" }, (request) => {
+ const query = new URL(request.url).searchParams.get("query")
+ if (cellQueries.has(query)) execQueries.push(query)
+ request.continue()
+ })
+ const expectNothingToRun = () => {
+ cy.contains(".Toastify__toast--error", "Nothing to run")
+ .should("be.visible")
+ .within(() => {
+ cy.get("button[aria-label='close']").click()
+ })
+ cy.then(() => expect(execQueries).to.deep.eq([]))
+ }
+
+ // When Cmd+Enter is pressed in Monaco with no query at the cursor
+ cy.withFocusedEditor((editor) => editor.getAction("notebook-run").run())
+
+ // Then it is a no-op, never an implicit Run All
+ expectNothingToRun()
+ cy.get("[data-hook='result-grid-tanstack']:visible").should("not.exist")
+
+ // When only the second statement is run
+ cy.focusNotebookCell()
+ cy.withFocusedEditor((editor) =>
+ editor.setPosition({ lineNumber: 3, column: 4 }),
+ )
+ cy.withFocusedEditor((editor) => editor.getAction("notebook-run").run())
+
+ // Then both statement tabs fit alongside a fully visible result row
+ cy.wrap(null).should(() => {
+ expect(execQueries).to.deep.eq(["select 2"])
+ })
+ cy.get("[role='tablist'] [data-hook='result-tab-not-run']").should(
+ "have.length",
+ 1,
+ )
+ cy.get("[role='tablist'] [data-hook='result-tab-success']").should(
+ "have.length",
+ 1,
+ )
+ cy.getGridRow(0).should("be.visible").and("contain", "2")
+ cy.then(() => {
+ execQueries.length = 0
+ })
+
+ // Given an explicit Run All has created a result tab for each statement
+ cy.withFocusedEditor((editor) => editor.getAction("notebook-run-all").run())
+ cy.get("[role='tablist'] [data-hook='result-tab-success']").should(
+ "have.length",
+ 2,
+ )
+ cy.then(() => {
+ execQueries.length = 0
+ })
+
+ // When the second result tab has focus and Cmd+Enter is pressed
+ cy.get("[role='tablist'] [role='tab']").eq(1).click()
+ cy.get("[role='tablist'] [role='tab']")
+ .eq(1)
+ .should("have.focus")
+ .and("have.attr", "title", "select 2")
+ cy.window().then((win) => {
+ win.dispatchEvent(
+ new win.KeyboardEvent("keydown", {
+ key: "Enter",
+ metaKey: Cypress.platform === "darwin",
+ ctrlKey: Cypress.platform !== "darwin",
+ bubbles: true,
+ }),
+ )
+ })
+
+ // Then only that tab's statement runs
+ cy.wrap(null).should(() => {
+ expect(execQueries).to.deep.eq(["select 2"])
+ })
+
+ // And the same active tab never becomes a fallback for Monaco
+ cy.focusNotebookCell()
+ cy.withFocusedEditor((editor) =>
+ editor.setPosition({ lineNumber: 2, column: 4 }),
+ )
+ cy.then(() => {
+ execQueries.length = 0
+ })
+ cy.withFocusedEditor((editor) => editor.getAction("notebook-run").run())
+ expectNothingToRun()
+ })
+
+ it("isolates a shared fragment that only matches part of a query", () => {
// Given the setting is turned off
openEditorSettings()
cy.getByDataHook("editor-settings-run-with-selection").click()
+ cy.getByDataHook("run-with-selection-off").click()
cy.getByDataHook("editor-settings-save").click()
- // And a buffer whose statement contains the shared fragment
+ // And a persisted buffer whose statement contains the shared fragment
cy.typeQueryDirectly("select 1 union all select 2;")
+ cy.waitForActiveBufferValue("select 1 union all select 2;")
+ cy.reload()
+ cy.getEditorContent().should("have.value", "select 1 union all select 2;")
// When a share link for the fragment auto-runs
cy.visit(
@@ -2212,7 +2657,11 @@ describe("editor settings", () => {
)
cy.getEditorContent().should("be.visible")
- // Then only the selected fragment runs, not the containing statement
+ // Then the fragment opens in its own buffer and only that SQL runs
+ cy.getEditorTabByTitle("Shared Query")
+ .should("be.visible")
+ .should("have.attr", "active")
+ cy.getEditorContent().should("have.value", "select 2")
cy.getGridRows().should("have.length", 1)
cy.getGridRow(0).should("contain", "2")
})
diff --git a/src/components/EditorSettingsModal/index.tsx b/src/components/EditorSettingsModal/index.tsx
index 4fb4d7fe7..601214950 100644
--- a/src/components/EditorSettingsModal/index.tsx
+++ b/src/components/EditorSettingsModal/index.tsx
@@ -5,9 +5,10 @@ import { Overlay } from "../Overlay"
import { ForwardRef } from "../ForwardRef"
import { Text } from "../Text"
import { Button } from "../Button"
-import { Switch } from "../Switch"
+import { SelectMenuControl } from "../SelectMenu"
import { Input } from "../Input"
import { useLocalStorage } from "../../providers/LocalStorageProvider"
+import type { RunWithSelectionMode } from "../../providers/LocalStorageProvider/types"
import {
isMaxColumnWidthDraftValid,
parseMaxColumnWidth,
@@ -77,6 +78,36 @@ const WidthInput = styled(Input)`
text-align: right;
`
+const ModeSelectField = styled.div`
+ width: 16rem;
+`
+
+const RUN_WITH_SELECTION_OPTIONS: {
+ label: string
+ value: RunWithSelectionMode
+ description: string
+ dataHook: string
+}[] = [
+ {
+ label: "Partial queries",
+ value: "partial",
+ description: "Selecting part of a query runs only that part.",
+ dataHook: "run-with-selection-partial",
+ },
+ {
+ label: "Complete queries",
+ value: "complete",
+ description: "Selecting part of a query runs the whole query.",
+ dataHook: "run-with-selection-complete",
+ },
+ {
+ label: "Off",
+ value: "off",
+ description: "The selection is ignored. The query at the cursor runs.",
+ dataHook: "run-with-selection-off",
+ },
+]
+
type SettingRowProps = {
label: string
description: string
@@ -117,9 +148,10 @@ const MAX_COLUMN_WIDTH_ID = "editor-settings-max-column-width"
const MAX_COLUMN_WIDTH_ERROR_ID = `${MAX_COLUMN_WIDTH_ID}-error`
const EditorSettingsForm = ({ onClose }: { onClose: () => void }) => {
- const { runWithSelection, maxColumnWidth, updateSettings } = useLocalStorage()
- const [runWithSelectionDraft, setRunWithSelectionDraft] =
- useState(runWithSelection)
+ const { runWithSelectionMode, maxColumnWidth, updateSettings } =
+ useLocalStorage()
+ const [runWithSelectionModeDraft, setRunWithSelectionModeDraft] =
+ useState(runWithSelectionMode)
const [maxColumnWidthDraft, setMaxColumnWidthDraft] = useState(
maxColumnWidth === "auto" ? "" : String(maxColumnWidth),
)
@@ -132,7 +164,7 @@ const EditorSettingsForm = ({ onClose }: { onClose: () => void }) => {
widthInputRef.current?.focus()
return
}
- updateSettings(StoreKey.RUN_WITH_SELECTION, runWithSelectionDraft)
+ updateSettings(StoreKey.RUN_WITH_SELECTION, runWithSelectionModeDraft)
updateSettings(
StoreKey.MAX_COLUMN_WIDTH,
parseMaxColumnWidth(maxColumnWidthDraft),
@@ -150,16 +182,23 @@ const EditorSettingsForm = ({ onClose }: { onClose: () => void }) => {
-
+
+
+ setRunWithSelectionModeDraft(value as RunWithSelectionMode)
+ }
+ options={RUN_WITH_SELECTION_OPTIONS}
+ />
+
void
@@ -183,7 +189,7 @@ const defaultValues: ContextProps = {
autoRefreshTables: true,
useNewGrid: true,
useQuickVis: false,
- runWithSelection: true,
+ runWithSelectionMode: "partial",
maxColumnWidth: "auto",
leftPanelState: defaultConfig.leftPanelState,
updateLeftPanelState: (_state: LeftPanelState) => undefined,
@@ -238,12 +244,10 @@ export const LocalStorageProvider = ({
getInitialBooleanFeature(QUICK_VIS_OVERRIDE),
)
- const [runWithSelection, setRunWithSelection] = useState(
- parseBoolean(
- getValue(StoreKey.RUN_WITH_SELECTION),
- defaultConfig.runWithSelection,
- ),
- )
+ const [runWithSelectionMode, setRunWithSelectionMode] =
+ useState(
+ parseRunWithSelectionMode(getValue(StoreKey.RUN_WITH_SELECTION)),
+ )
const [maxColumnWidth, setMaxColumnWidth] = useState(
parseMaxColumnWidth(getValue(StoreKey.MAX_COLUMN_WIDTH)),
@@ -353,7 +357,7 @@ export const LocalStorageProvider = ({
setUseQuickVis(value === "true")
break
case StoreKey.RUN_WITH_SELECTION:
- setRunWithSelection(value === "true")
+ setRunWithSelectionMode(parseRunWithSelectionMode(value))
break
case StoreKey.MAX_COLUMN_WIDTH:
setMaxColumnWidth(parseMaxColumnWidth(value))
@@ -388,7 +392,7 @@ export const LocalStorageProvider = ({
autoRefreshTables,
useNewGrid,
useQuickVis,
- runWithSelection,
+ runWithSelectionMode,
maxColumnWidth,
leftPanelState,
updateLeftPanelState,
@@ -408,7 +412,7 @@ export const LocalStorageProvider = ({
autoRefreshTables,
useNewGrid,
useQuickVis,
- runWithSelection,
+ runWithSelectionMode,
maxColumnWidth,
leftPanelState,
updateLeftPanelState,
diff --git a/src/providers/LocalStorageProvider/types.ts b/src/providers/LocalStorageProvider/types.ts
index 7135eafe7..aad36d921 100644
--- a/src/providers/LocalStorageProvider/types.ts
+++ b/src/providers/LocalStorageProvider/types.ts
@@ -29,6 +29,8 @@ export type AiAssistantSettings = {
export type SettingsType = string | boolean | number | AiAssistantSettings
+export type RunWithSelectionMode = "partial" | "complete" | "off"
+
export enum LeftPanelType {
DATASOURCES = "datasources",
SEARCH = "search",
@@ -55,7 +57,7 @@ export type LocalConfig = {
autoRefreshTables: boolean
useNewGrid: boolean
useQuickVis: boolean
- runWithSelection: boolean
+ runWithSelectionMode: RunWithSelectionMode
maxColumnWidth: MaxColumnWidth
leftPanelState: LeftPanelState
aiAssistantSettings: AiAssistantSettings
diff --git a/src/providers/LocalStorageProvider/utils.test.ts b/src/providers/LocalStorageProvider/utils.test.ts
index c32141145..dfbd12b2d 100644
--- a/src/providers/LocalStorageProvider/utils.test.ts
+++ b/src/providers/LocalStorageProvider/utils.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest"
-import { isMaxColumnWidthDraftValid, parseMaxColumnWidth } from "./utils"
+import {
+ isMaxColumnWidthDraftValid,
+ parseMaxColumnWidth,
+ parseRunWithSelectionMode,
+} from "./utils"
describe("parseMaxColumnWidth", () => {
it("parses a stored number", () => {
@@ -53,3 +57,28 @@ describe("isMaxColumnWidthDraftValid", () => {
expect(isMaxColumnWidthDraftValid("1e3")).toBe(false)
})
})
+
+describe("parseRunWithSelectionMode", () => {
+ it.each(["partial", "complete", "off"] as const)(
+ "keeps the stored %s mode",
+ (mode) => {
+ expect(parseRunWithSelectionMode(mode)).toBe(mode)
+ },
+ )
+
+ it("migrates the legacy boolean values", () => {
+ // Given values stored by the old on/off switch
+ // When parsing them
+ // Then true maps to partial and false maps to off
+ expect(parseRunWithSelectionMode("true")).toBe("partial")
+ expect(parseRunWithSelectionMode("false")).toBe("off")
+ })
+
+ it("falls back to partial for missing or unknown values", () => {
+ // Given no stored value or a corrupted one
+ // When parsing them
+ // Then the default partial mode applies
+ expect(parseRunWithSelectionMode("")).toBe("partial")
+ expect(parseRunWithSelectionMode("garbage")).toBe("partial")
+ })
+})
diff --git a/src/providers/LocalStorageProvider/utils.ts b/src/providers/LocalStorageProvider/utils.ts
index 1793d18c5..2184d5a52 100644
--- a/src/providers/LocalStorageProvider/utils.ts
+++ b/src/providers/LocalStorageProvider/utils.ts
@@ -24,10 +24,20 @@
import { MAX_COLUMN_WIDTH_BOUNDS } from "../../components/ResultGrid/dimensions"
import type { MaxColumnWidth } from "../../components/ResultGrid/types"
+import type { RunWithSelectionMode } from "./types"
export const parseBoolean = (value: string, defaultValue: boolean): boolean =>
value ? value === "true" : defaultValue
+export const parseRunWithSelectionMode = (
+ value: string,
+): RunWithSelectionMode => {
+ if (value === "partial" || value === "complete" || value === "off")
+ return value
+ if (value === "false") return "off"
+ return "partial"
+}
+
export const parseInteger = (value: string, defaultValue: number): number =>
isNaN(parseInt(value)) ? defaultValue : parseInt(value)
diff --git a/src/scenes/Editor/ButtonBar/index.tsx b/src/scenes/Editor/ButtonBar/index.tsx
index cb33e2c77..157752e3b 100644
--- a/src/scenes/Editor/ButtonBar/index.tsx
+++ b/src/scenes/Editor/ButtonBar/index.tsx
@@ -12,6 +12,7 @@ import { RunningType } from "../../../store/Query/types"
import { useQueryExecutionState } from "../../../hooks/useQueryExecutionState"
type ButtonBarProps = {
+ onRunQuery: () => void
onTriggerRunScript: (runAll?: boolean) => void
onCopyLinkAllQueries: () => void
isTemporary: boolean | undefined
@@ -133,6 +134,7 @@ const shortcutTitles = {
const copyLinkShortcutTitle = `Copy query link (${altOption}+Shift+L)`
const ButtonBar = ({
+ onRunQuery,
onTriggerRunScript,
onCopyLinkAllQueries,
isTemporary,
@@ -153,12 +155,8 @@ const ButtonBar = ({
dispatch(actions.query.toggleRunning())
return
}
- if (queriesToRun.length > 1) {
- onTriggerRunScript()
- } else {
- dispatch(actions.query.toggleRunning())
- }
- }, [dispatch, running, queriesToRun, onTriggerRunScript])
+ onRunQuery()
+ }, [dispatch, running, onRunQuery])
const handleClickScriptButton = useCallback(() => {
onTriggerRunScript(true)
@@ -291,7 +289,7 @@ const ButtonBar = ({
return queriesToRun[0].selection ? "Run selected query" : "Run query"
}
if (numQueries > 1) {
- return `Run ${numQueries} selected queries`
+ return "Run selected queries"
}
return "Run query"
}
diff --git a/src/scenes/Editor/Monaco/QueryDropdown.tsx b/src/scenes/Editor/Monaco/QueryDropdown.tsx
index 8e265261c..1a90716d9 100644
--- a/src/scenes/Editor/Monaco/QueryDropdown.tsx
+++ b/src/scenes/Editor/Monaco/QueryDropdown.tsx
@@ -6,6 +6,10 @@ import { DropdownMenu } from "../../../components/DropdownMenu"
import { PlayFilled } from "../../../components/icons/play-filled"
import { AISparkle } from "../../../components/AISparkle"
import type { Request } from "./utils"
+import {
+ extractFullQueryText,
+ extractQueryTextToRun,
+} from "./queryDropdownUtils"
const HiddenTrigger = styled.div<{ style?: { top: string; left: string } }>`
position: fixed;
@@ -48,14 +52,6 @@ export const QueryDropdown: React.FC = ({
onOpenChange(isOpen)
}
- const extractQueryTextToRun = (query: Request) => {
- if (!query) return "query"
- const queryText = query.selection ? query.selection.queryText : query.query
- return queryText.length > 30
- ? `"${queryText.substring(0, 30)}..."`
- : `"${queryText}"`
- }
-
const isExplainDisabled = (query: Request) => {
if (!query) return false
const queryText = query.selection ? query.selection.queryText : query.query
@@ -124,7 +120,7 @@ export const QueryDropdown: React.FC = ({
data-hook={`dropdown-item-copy-query-link-${index}`}
icon={}
>
- Copy link to {extractQueryTextToRun(query)}
+ Copy link to {extractFullQueryText(query)}
,
)
}
@@ -159,7 +155,7 @@ export const QueryDropdown: React.FC = ({
data-hook="dropdown-item-copy-query-link"
icon={}
>
- Copy link to {extractQueryTextToRun(queriesRef.current[0])}
+ Copy link to {extractFullQueryText(queriesRef.current[0])}
,
]}
diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx
index bf4d47418..90c117222 100644
--- a/src/scenes/Editor/Monaco/index.tsx
+++ b/src/scenes/Editor/Monaco/index.tsx
@@ -31,6 +31,7 @@ import {
} from "../../../providers/AIStatusProvider"
import { useAIConversationActions } from "../../../providers/AIConversationProvider"
import { useLocalStorage } from "../../../providers/LocalStorageProvider"
+import type { RunWithSelectionMode } from "../../../providers/LocalStorageProvider/types"
import { actions, selectors } from "../../../store"
import { RunningType } from "../../../store/Query/types"
import { MAX_CELL_LINES } from "../../../store/notebook"
@@ -52,8 +53,10 @@ import {
clearModelMarkers,
clearValidationMarkers,
findMatches,
+ isFullQueryMatch,
getErrorRange,
getQueryFromCursor,
+ getSelectedText,
getQueryRequestFromEditor,
getQueryRequestFromLastExecutedQuery,
QuestDBLanguageName,
@@ -75,6 +78,7 @@ import {
getQueryStartOffset,
getQueriesToRun,
getQueriesStartingFromLine,
+ getStatementOffsets,
readShareLinkParams,
clearShareLinkParams,
buildShareLinkUrl,
@@ -103,6 +107,14 @@ type IndividualQueryResult = {
| null
}
+type ScriptRunPlan = Readonly<{
+ runAll: boolean
+ queries?: readonly Request[]
+ bufferId: number
+ model: editor.ITextModel
+ modelVersionId: number
+}>
+
export const LINE_NUMBER_HARD_LIMIT = MAX_CELL_LINES
const Content = styled(PaneContent)<{ $hidden?: boolean }>`
@@ -282,7 +294,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
} = editorContext
const { quest, questExecution } = useContext(QuestContext)
const { canUse: canUseAI, status: aiStatus } = useAIStatus()
- const { runWithSelection } = useLocalStorage()
+ const { runWithSelectionMode } = useLocalStorage()
const {
handleGlyphClick,
hasConversationForQuery,
@@ -315,7 +327,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
const queryOffsetsRef = useRef<
{ startOffset: number; endOffset: number }[] | null
>([])
- const pendingScriptRunRef = useRef<{ runAll: boolean } | undefined>(undefined)
+ const pendingScriptRunRef = useRef(undefined)
+ const scriptRunPlanRef = useRef(undefined)
+ const pendingQueryRequestRef = useRef(undefined)
const queriesToRunRef = useRef([])
const scriptStopRef = useRef(false)
const stopAfterFailureRef = useRef(true)
@@ -328,8 +342,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
const queryNotificationsRef = useRef(queryNotifications)
const activeNotificationRef = useRef(activeNotification)
const canUseAIRef = useRef(canUseAI)
- const runWithSelectionRef = useRef(runWithSelection)
- const shareLinkSelectionRunRef = useRef(false)
+ const runWithSelectionModeRef = useRef(runWithSelectionMode)
const hasConversationForQueryRef = useRef(hasConversationForQuery)
const shiftQueryKeysForBufferRef = useRef(shiftQueryKeysForBuffer)
const findQueryByConversationIdRef = useRef(findQueryByConversationId)
@@ -456,9 +469,13 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
endColumn: endPosition.column,
})
} else {
+ const selection = editor.getSelection()
const queryInCursor = getQueryFromCursor(editor)
if (
queryInCursor &&
+ (runWithSelectionModeRef.current === "off" ||
+ !selection ||
+ selection.isEmpty()) &&
createQueryKeyFromRequest(editor, queryInCursor) ===
createQueryKeyFromRequest(editor, query)
) {
@@ -511,9 +528,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
}
}
- const handleRunQuery = (query: Request) => {
+ const handleRunQuery = (query: Request, preserveSelection = false) => {
setDropdownOpen(false)
- runQueryAction(query, RunningType.QUERY)
+ runQueryAction(query, RunningType.QUERY, preserveSelection)
}
const handleExplainQuery = (query: Request) => {
@@ -528,7 +545,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
return
}
const sql = requests
- .map((r) => (r.selection ? r.selection.queryText : r.query))
+ .map((r) => r.query)
.join(";\n\n")
.concat(";")
@@ -553,28 +570,66 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
const syncQueriesToRun = (
editor: editor.IStandaloneCodeEditor,
- runWithSelection: boolean,
+ selectionMode: RunWithSelectionMode,
): Request[] => {
const queriesToRun = getQueriesToRun(
editor,
queryOffsetsRef.current ?? [],
- runWithSelection,
+ selectionMode,
)
queriesToRunRef.current = queriesToRun
dispatch(actions.query.setQueriesToRun(queriesToRun))
return queriesToRun
}
+ const createScriptRunPlan = (
+ runAll: boolean,
+ queries?: readonly Request[],
+ ): ScriptRunPlan | undefined => {
+ const model = editorRef.current?.getModel()
+ if (!model) return
+
+ return {
+ runAll,
+ queries: queries ? [...queries] : undefined,
+ bufferId: activeBufferRef.current.id as number,
+ model,
+ modelVersionId: model.getVersionId(),
+ }
+ }
+
+ const handlePrimaryRun = () => {
+ const editor = editorRef.current
+ if (!editor) return
+
+ const selectionMode = runWithSelectionModeRef.current
+ const selectedText = getSelectedText(editor)
+ const resolvesSelection = selectionMode !== "off" && Boolean(selectedText)
+ const queries = resolvesSelection
+ ? getQueriesToRun(editor, getStatementOffsets(editor), selectionMode, {
+ queryOffsetsAreFresh: true,
+ })
+ : [getQueryRequestFromEditor(editor, selectionMode)].filter(
+ (request): request is Request => request !== undefined,
+ )
+
+ if (queries.length === 1) {
+ handleRunQuery(queries[0])
+ } else if (queries.length > 1) {
+ handleTriggerRunScript(false, queries)
+ }
+ }
+
const handleCopyLinkSelection = () => {
void trackEvent(ConsoleEvent.EDITOR_COPY_QUERY_LINK, {
from: "shortcut",
})
const editor = editorRef.current
if (!editor) return
- // Link sharing always honors the selection, independent of the
- // run-with-selection setting.
+ // Link sharing always expands the selection to complete queries,
+ // independent of the run-with-selection setting.
buildAndCopyShareLink(
- getQueriesToRun(editor, queryOffsetsRef.current ?? [], true),
+ getQueriesToRun(editor, queryOffsetsRef.current ?? [], "complete"),
)
}
@@ -590,6 +645,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
const runQueryAction = (
query: Request,
type: RunningType.QUERY | RunningType.EXPLAIN,
+ preserveSelection = false,
) => {
const editor = editorRef.current
const model = editor?.getModel()
@@ -608,6 +664,8 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
}
const targetBufferId = activeBufferRef.current.id as number
+ const targetModel = model
+ const targetModelVersionId = model.getVersionId()
const queryKey = createQueryKeyFromRequest(editor, query)
questExecution.requestExecution({
@@ -619,7 +677,21 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
},
bufferId: targetBufferId,
execute: () => {
- setCursorBeforeRunning(query)
+ if (
+ activeBufferRef.current.id !== targetBufferId ||
+ editorRef.current?.getModel() !== targetModel ||
+ targetModel.getVersionId() !== targetModelVersionId
+ ) {
+ questExecution.releaseExecution(queryKey)
+ toast.error(
+ "The editor changed before the query could run. Run it again.",
+ )
+ return
+ }
+ pendingQueryRequestRef.current = query
+ if (!preserveSelection) {
+ setCursorBeforeRunning(query)
+ }
toggleRunning(type)
},
queryKey,
@@ -632,9 +704,17 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
pendingScriptRunRef.current = undefined
- if (pending.runAll) {
- queriesToRunRef.current = []
+ if (
+ activeBufferRef.current.id !== pending.bufferId ||
+ editorRef.current.getModel() !== pending.model ||
+ pending.model.getVersionId() !== pending.modelVersionId
+ ) {
+ toast.error(
+ "The editor changed before the queries could run. Run them again.",
+ )
+ return
}
+ scriptRunPlanRef.current = pending
dispatch(actions.query.toggleRunning(RunningType.SCRIPT))
}
@@ -954,11 +1034,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
editor,
monaco,
runQuery: () => {
- if (queriesToRunRef.current.length === 1) {
- handleRunQuery(queriesToRunRef.current[0])
- } else if (queriesToRunRef.current.length > 1) {
- handleTriggerRunScript()
- }
+ handlePrimaryRun()
},
runScript: () => {
handleTriggerRunScript(true)
@@ -992,7 +1068,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
}
cursorChangeTimeoutRef.current = window.setTimeout(() => {
- syncQueriesToRun(editor, runWithSelectionRef.current)
+ syncQueriesToRun(editor, runWithSelectionModeRef.current)
if (monacoRef.current && editorRef.current) {
applyLineMarkings(monaco, editor, e.source)
@@ -1166,7 +1242,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
applyGlyphsAndLineMarkings(monaco, editor)
}
- syncQueriesToRun(editor, runWithSelectionRef.current)
+ syncQueriesToRun(editor, runWithSelectionModeRef.current)
contentJustChangedRef.current = false
if (notificationKeyUpdates.size > 0) {
@@ -1271,11 +1347,15 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
const trimmedQuery = query.trim()
// Find if the query is already in the editor
const matches = findMatches(model, trimmedQuery)
- if (matches && matches.length > 0) {
- editor.setSelection(matches[0].range)
+ const statementOffsets = getStatementOffsets(editor)
+ const fullQueryMatch = matches?.find((match) =>
+ isFullQueryMatch(editor, match.range, statementOffsets),
+ )
+ if (fullQueryMatch) {
+ editor.setSelection(fullQueryMatch.range)
editor.revealPositionInCenter({
- lineNumber: matches[0].range.startLineNumber,
- column: matches[0].range.startColumn,
+ lineNumber: fullQueryMatch.range.startLineNumber,
+ column: fullQueryMatch.range.startColumn,
})
// otherwise, open the query in a new buffer
} else {
@@ -1299,12 +1379,13 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
// Initial decoration setup
applyGlyphsAndLineMarkings(monaco, editor)
- // A ?query link selects its statements to run them all; that selection
- // must be honored even when the run-with-selection setting is off.
+ // A ?query link selects its statements to run them all; the selection
+ // always expands to complete queries, even when the run-with-selection
+ // setting is off.
const runsShareLinkSelection = Boolean(query && executeQuery)
const queriesToRun = syncQueriesToRun(
editor,
- runsShareLinkSelection || runWithSelectionRef.current,
+ runsShareLinkSelection ? "complete" : runWithSelectionModeRef.current,
)
if (!query || !executeQuery) {
@@ -1312,10 +1393,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
} else if (queriesToRun.length > 0) {
const runQueries = () => {
if (queriesToRun.length > 1) {
- handleTriggerRunScript()
+ handleTriggerRunScript(false, queriesToRun)
} else {
- shareLinkSelectionRunRef.current = true
- toggleRunning()
+ handleRunQuery(queriesToRun[0], true)
}
}
@@ -1532,33 +1612,41 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
setScriptConfirmationOpen(open)
}
- const handleTriggerRunScript = (runAll?: boolean) => {
- if (running === RunningType.SCRIPT) {
+ const handleTriggerRunScript = (
+ runAll?: boolean,
+ selectedQueries?: readonly Request[],
+ ) => {
+ if (runningValueRef.current === RunningType.SCRIPT && runAll) {
dispatch(actions.query.toggleRunning())
return
}
+ const runPlan = createScriptRunPlan(Boolean(runAll), selectedQueries)
+ if (!runPlan) return
+
+ if (!runPlan.runAll && (!runPlan.queries || runPlan.queries.length < 2)) {
+ return
+ }
+
void trackEvent(ConsoleEvent.EDITOR_RUN_MULTIPLE, {
- queryCount: queriesToRunRef.current?.length ?? 0,
+ queryCount: runPlan.queries?.length ?? 0,
runAll,
})
- const hasMultipleSelection = queriesToRunRef.current.length > 1
- const runsAllQueries = Boolean(runAll) || !hasMultipleSelection
-
if (
runningValueRef.current === RunningType.NONE &&
!questExecution.isAnyRunning()
) {
- if (runsAllQueries) {
+ if (runPlan.runAll) {
setScriptConfirmation(true)
} else {
+ scriptRunPlanRef.current = runPlan
dispatch(actions.query.toggleRunning(RunningType.SCRIPT))
}
return
}
- pendingScriptRunRef.current = { runAll: runsAllQueries }
+ pendingScriptRunRef.current = runPlan
setScriptConfirmation(true)
}
@@ -1576,7 +1664,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
return
}
- queriesToRunRef.current = []
+ const runPlan = createScriptRunPlan(true)
+ if (!runPlan) return
+ scriptRunPlanRef.current = runPlan
dispatch(actions.query.toggleRunning(RunningType.SCRIPT))
}
@@ -1612,10 +1702,25 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
const editor = editorRef.current
const monaco = monacoRef.current
if (!editor || !monaco) return
- const queriesToRun =
- queriesToRunRef.current && queriesToRunRef.current.length > 1
- ? queriesToRunRef.current
- : undefined
+ const runPlan = scriptRunPlanRef.current
+ scriptRunPlanRef.current = undefined
+ if (
+ !runPlan ||
+ activeBufferRef.current.id !== runPlan.bufferId ||
+ editor.getModel() !== runPlan.model ||
+ runPlan.model.getVersionId() !== runPlan.modelVersionId
+ ) {
+ toast.error(
+ "The editor changed before the queries could run. Run them again.",
+ )
+ dispatch(actions.query.stopRunning())
+ if (scriptQueryKeyRef.current !== null) {
+ questExecution.releaseExecution(scriptQueryKeyRef.current)
+ scriptQueryKeyRef.current = null
+ }
+ return
+ }
+ const queriesToRun = runPlan.runAll ? undefined : runPlan.queries
const runningAllQueries = !queriesToRun
// Clear all notifications & execution refs for the buffer
const activeBufferId = activeBuffer.id as number
@@ -1755,7 +1860,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
? NotificationType.SUCCESS
: NotificationType.ERROR,
},
- activeBufferRef.current.id as number,
+ activeBufferRef.current.id,
),
)
setTabsDisabled(false)
@@ -1771,11 +1876,11 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
}
useEffect(() => {
- runWithSelectionRef.current = runWithSelection
+ runWithSelectionModeRef.current = runWithSelectionMode
const editor = editorRef.current
if (!editor) return
- syncQueriesToRun(editor, runWithSelection)
- }, [runWithSelection])
+ syncQueriesToRun(editor, runWithSelectionMode)
+ }, [runWithSelectionMode])
useEffect(() => {
canUseAIRef.current = canUseAI
@@ -1849,17 +1954,13 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
if (![RunningType.NONE, RunningType.SCRIPT].includes(running)) {
applyGlyphsAndLineMarkings(monaco, editor)
- // Consumed once: only the share-link mount path sets it, right before
- // dispatching this run.
- const honorShareLinkSelection = shareLinkSelectionRunRef.current
- shareLinkSelectionRunRef.current = false
+ const capturedRequest = pendingQueryRequestRef.current
+ pendingQueryRequestRef.current = undefined
const request =
running === RunningType.REFRESH
? getQueryRequestFromLastExecutedQuery(lastExecutedQuery)
- : getQueryRequestFromEditor(
- editor,
- honorShareLinkSelection || runWithSelectionRef.current,
- )
+ : (capturedRequest ??
+ getQueryRequestFromEditor(editor, runWithSelectionModeRef.current))
const isRunningExplain = running === RunningType.EXPLAIN
@@ -2354,6 +2455,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => {
{!hidden && (
{
)}
{isPendingSelectionRun
- ? `You are about to run ${queriesToRunRef.current.length} selected queries. This action may modify or delete your data permanently.`
+ ? `You are about to run ${pendingScriptRunRef.current?.queries?.length ?? 0} selected queries. This action may modify or delete your data permanently.`
: "You are about to run all queries in this tab. This action may modify or delete your data permanently."}
{!isPendingSelectionRun && (
diff --git a/src/scenes/Editor/Monaco/queryDropdownUtils.test.ts b/src/scenes/Editor/Monaco/queryDropdownUtils.test.ts
new file mode 100644
index 000000000..06b80bec0
--- /dev/null
+++ b/src/scenes/Editor/Monaco/queryDropdownUtils.test.ts
@@ -0,0 +1,31 @@
+import {
+ extractFullQueryText,
+ extractQueryTextToRun,
+} from "./queryDropdownUtils"
+
+describe("query dropdown labels", () => {
+ it("handles the initial state before a query is available", () => {
+ expect(extractQueryTextToRun(undefined)).toBe("query")
+ expect(extractFullQueryText(undefined)).toBe("query")
+ })
+
+ it("uses the selection for run labels and the whole query for link labels", () => {
+ const query = {
+ query: "SELECT first, second FROM long_table_name",
+ row: 0,
+ column: 1,
+ endRow: 0,
+ endColumn: 42,
+ selection: {
+ startOffset: 7,
+ endOffset: 12,
+ queryText: "first",
+ },
+ }
+
+ expect(extractQueryTextToRun(query)).toBe('"first"')
+ expect(extractFullQueryText(query)).toBe(
+ '"SELECT first, second FROM long..."',
+ )
+ })
+})
diff --git a/src/scenes/Editor/Monaco/queryDropdownUtils.ts b/src/scenes/Editor/Monaco/queryDropdownUtils.ts
new file mode 100644
index 000000000..179b2a117
--- /dev/null
+++ b/src/scenes/Editor/Monaco/queryDropdownUtils.ts
@@ -0,0 +1,16 @@
+import type { Request } from "./utils"
+
+const formatQueryText = (queryText: string) =>
+ queryText.length > 30
+ ? `"${queryText.substring(0, 30)}..."`
+ : `"${queryText}"`
+
+export const extractQueryTextToRun = (query?: Request) => {
+ if (!query) return "query"
+ return formatQueryText(
+ query.selection ? query.selection.queryText : query.query,
+ )
+}
+
+export const extractFullQueryText = (query?: Request) =>
+ query ? formatQueryText(query.query) : "query"
diff --git a/src/scenes/Editor/Monaco/utils.test.ts b/src/scenes/Editor/Monaco/utils.test.ts
index 97ebbaa08..37898ade6 100644
--- a/src/scenes/Editor/Monaco/utils.test.ts
+++ b/src/scenes/Editor/Monaco/utils.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"
-import type { editor } from "monaco-editor"
+import type { editor, IRange } from "monaco-editor"
import {
getQueriesFromText,
isCursorInComment,
@@ -13,6 +13,10 @@ import {
isInflightQueryStillInPlace,
shiftSelection,
applyQueryKeyUpdates,
+ getStatementOffsets,
+ isFullQueryMatch,
+ joinQueryTexts,
+ resolveSelectionRun,
} from "./utils"
type SingleLineSelection = { startColumn: number; endColumn: number }
@@ -23,6 +27,7 @@ const makeSingleLineEditor = (
selection: SingleLineSelection | null,
) => {
let currentSelection = selection
+ let currentCursorColumn = cursorColumn
const toSelection = (sel: SingleLineSelection) => ({
startLineNumber: 1,
@@ -39,12 +44,14 @@ const makeSingleLineEditor = (
text.substring(range.startColumn - 1, range.endColumn - 1),
getOffsetAt: (position: { column: number }) => position.column - 1,
getPositionAt: (offset: number) => ({ lineNumber: 1, column: offset + 1 }),
+ getLineCount: () => 1,
+ getLineContent: () => text,
}
return {
getModel: () => model,
getValue: () => text,
- getPosition: () => ({ lineNumber: 1, column: cursorColumn }),
+ getPosition: () => ({ lineNumber: 1, column: currentCursorColumn }),
getSelection: () =>
currentSelection ? toSelection(currentSelection) : null,
setSelection: (range: { startColumn: number; endColumn: number }) => {
@@ -52,10 +59,74 @@ const makeSingleLineEditor = (
startColumn: range.startColumn,
endColumn: range.endColumn,
}
+ currentCursorColumn = range.endColumn
},
} as unknown as editor.IStandaloneCodeEditor
}
+const makeMultiLineEditor = (text: string, selection?: IRange) => {
+ const lines = text.split("\n")
+ const getOffsetAt = (position: { lineNumber: number; column: number }) =>
+ lines
+ .slice(0, position.lineNumber - 1)
+ .reduce((offset, line) => offset + line.length + 1, 0) +
+ position.column -
+ 1
+ const getPositionAt = (offset: number) => {
+ let remaining = offset
+ for (let index = 0; index < lines.length; index++) {
+ if (remaining <= lines[index].length) {
+ return { lineNumber: index + 1, column: remaining + 1 }
+ }
+ remaining -= lines[index].length + 1
+ }
+ return {
+ lineNumber: lines.length,
+ column: lines[lines.length - 1].length + 1,
+ }
+ }
+ const model = {
+ getOffsetAt,
+ getPositionAt,
+ getValueInRange: (range: IRange) =>
+ text.substring(
+ getOffsetAt({
+ lineNumber: range.startLineNumber,
+ column: range.startColumn,
+ }),
+ getOffsetAt({
+ lineNumber: range.endLineNumber,
+ column: range.endColumn,
+ }),
+ ),
+ getLineCount: () => lines.length,
+ getLineContent: (lineNumber: number) => lines[lineNumber - 1],
+ }
+
+ return {
+ getModel: () => model,
+ getValue: () => text,
+ getPosition: () => getPositionAt(text.length),
+ getSelection: () =>
+ selection
+ ? {
+ ...selection,
+ getStartPosition: () => ({
+ lineNumber: selection.startLineNumber,
+ column: selection.startColumn,
+ }),
+ getEndPosition: () => ({
+ lineNumber: selection.endLineNumber,
+ column: selection.endColumn,
+ }),
+ isEmpty: () =>
+ selection.startLineNumber === selection.endLineNumber &&
+ selection.startColumn === selection.endColumn,
+ }
+ : null,
+ } as unknown as editor.IStandaloneCodeEditor
+}
+
describe("getQueriesFromText", () => {
it("splits two simple statements", () => {
expect(getQueriesFromText("SELECT 1; SELECT 2;")).toEqual([
@@ -140,6 +211,25 @@ SELECT * FROM sampled;`
})
})
+describe("joinQueryTexts", () => {
+ it("keeps separators outside trailing line comments", () => {
+ const queries = [
+ "select ts, price\nfrom trades -- last hour",
+ "select count(*) from trades",
+ "select max(price) from trades -- final aggregate",
+ ]
+
+ const sql = joinQueryTexts(queries)
+
+ expect(sql).toBe(
+ "select ts, price\nfrom trades -- last hour\n;\n" +
+ "select count(*) from trades\n;\n" +
+ "select max(price) from trades -- final aggregate",
+ )
+ expect(getQueriesFromText(sql)).toEqual(queries)
+ })
+})
+
describe("isCursorInComment", () => {
it("returns false when cursor is in normal SQL", () => {
const text = "SELECT * FROM table"
@@ -379,59 +469,243 @@ describe("isCursorInQuotedIdentifier", () => {
})
})
-describe("run with selection gating", () => {
+describe("run with selection modes", () => {
// "SELECT 11" spans columns 1..10 on the single line; the cursor sits inside it.
const TEXT = "SELECT 11; SELECT 22"
const FIRST_STATEMENT_SELECTION = { startColumn: 1, endColumn: 10 }
+ const PARTIAL_FIRST_STATEMENT_SELECTION = { startColumn: 4, endColumn: 10 }
+ // "11; SELE" — cuts into both statements without covering either fully.
+ const CROSS_STATEMENT_SELECTION = { startColumn: 8, endColumn: 16 }
const QUERY_OFFSETS = [
{ startOffset: 0, endOffset: 9 },
{ startOffset: 11, endOffset: 20 },
]
describe("getQueriesToRun", () => {
- it("runs only the selected text when enabled", () => {
- // Given the first statement is selected and running with selection is enabled
+ it("runs only the selected text in partial mode", () => {
+ // Given the first statement is selected in partial mode
const editor = makeSingleLineEditor(TEXT, 3, FIRST_STATEMENT_SELECTION)
// When resolving the queries to run
- const result = getQueriesToRun(editor, QUERY_OFFSETS, true)
+ const result = getQueriesToRun(editor, QUERY_OFFSETS, "partial")
// Then the run carries the selection
expect(result.length).toBeGreaterThan(0)
expect(result.some((request) => request.selection)).toBe(true)
})
- it("ignores the selection and runs the cursor query when disabled", () => {
- // Given the first statement is selected but running with selection is disabled
+ it("expands a partial cross-statement selection to both whole queries in complete mode", () => {
+ // Given a selection cutting into both statements in complete mode
+ const editor = makeSingleLineEditor(TEXT, 3, CROSS_STATEMENT_SELECTION)
+
+ // When resolving the queries to run
+ const result = getQueriesToRun(editor, QUERY_OFFSETS, "complete")
+
+ // Then both statements run fully with no selection attached
+ expect(result.map((request) => request.query)).toEqual([
+ "SELECT 11",
+ "SELECT 22",
+ ])
+ expect(result.every((request) => !request.selection)).toBe(true)
+ })
+
+ it("expands a partial single-statement selection to its whole query in complete mode", () => {
+ // Given a selection covering part of the first statement in complete mode
+ const editor = makeSingleLineEditor(
+ TEXT,
+ 3,
+ PARTIAL_FIRST_STATEMENT_SELECTION,
+ )
+
+ // When resolving the queries to run
+ const result = getQueriesToRun(editor, QUERY_OFFSETS, "complete")
+
+ // Then only that statement runs fully with no selection attached
+ expect(result.map((request) => request.query)).toEqual(["SELECT 11"])
+ expect(result.every((request) => !request.selection)).toBe(true)
+ })
+
+ it("ignores the selection and runs the cursor query when off", () => {
+ // Given the first statement is selected but the mode is off
const editor = makeSingleLineEditor(TEXT, 3, FIRST_STATEMENT_SELECTION)
// When resolving the queries to run
- const result = getQueriesToRun(editor, QUERY_OFFSETS, false)
+ const result = getQueriesToRun(editor, QUERY_OFFSETS, "off")
// Then it falls back to the cursor query with no selection attached
expect(result).toEqual([getQueryFromCursor(editor)])
expect(result.every((request) => !request.selection)).toBe(true)
})
+
+ it.each([
+ ["whitespace", { startColumn: 7, endColumn: 8 }],
+ ["a statement separator", { startColumn: 10, endColumn: 11 }],
+ ["a separator and whitespace", { startColumn: 10, endColumn: 12 }],
+ ])("runs nothing when the selection is only %s", (_label, selection) => {
+ // Given a selection carrying no statement content, with the cursor
+ // sitting inside the first statement
+ const editor = makeSingleLineEditor(TEXT, 3, selection)
+
+ // When resolving the queries to run in each honouring mode
+ // Then neither mode falls back to the cursor query — a deliberate
+ // selection of nothing runnable runs nothing
+ expect(getQueriesToRun(editor, QUERY_OFFSETS, "partial")).toEqual([])
+ expect(getQueriesToRun(editor, QUERY_OFFSETS, "complete")).toEqual([])
+ })
+
+ it("still runs the cursor query when there is no selection at all", () => {
+ // Given a collapsed cursor inside the first statement
+ const editor = makeSingleLineEditor(TEXT, 3, null)
+
+ // When resolving the queries to run
+ // Then the cursor query runs, as it does with the mode off
+ expect(getQueriesToRun(editor, QUERY_OFFSETS, "partial")).toEqual([
+ getQueryFromCursor(editor),
+ ])
+ })
+
+ it("keeps off and complete distinct for a cross-statement selection", () => {
+ // Given a valid selection that starts at the cursor and crosses into the
+ // second statement
+ const selection = { startColumn: 3, endColumn: 16 }
+ const editor = makeSingleLineEditor(TEXT, 3, selection)
+
+ // When resolving the same selection in each mode
+ const complete = getQueriesToRun(editor, QUERY_OFFSETS, "complete")
+ const off = getQueriesToRun(editor, QUERY_OFFSETS, "off")
+
+ // Then complete expands both touched statements while off uses only the
+ // cursor statement
+ expect(complete.map((request) => request.query)).toEqual([
+ "SELECT 11",
+ "SELECT 22",
+ ])
+ expect(off.map((request) => request.query)).toEqual(["SELECT 11"])
+ })
+
+ it("returns nothing when the selection covers only a comment line between statements", () => {
+ // Given a commented-out statement between two live statements, with a
+ // word inside the comment selected
+ const text =
+ "SELECT * FROM trades;\n-- old: DELETE FROM trades\nDROP TABLE trades;"
+ const commentWordSelection = {
+ startLineNumber: 2,
+ startColumn: 9,
+ endLineNumber: 2,
+ endColumn: 15,
+ }
+ const makeEditor = () => makeMultiLineEditor(text, commentWordSelection)
+ const offsets = getStatementOffsets(makeEditor())
+
+ // When resolving the queries to run in each selection mode
+ const complete = getQueriesToRun(makeEditor(), offsets, "complete")
+ const partial = getQueriesToRun(makeEditor(), offsets, "partial")
+ const off = getQueriesToRun(makeEditor(), offsets, "off")
+
+ // Then no mode runs or expands into a statement the user never touched;
+ // off falls back to the query at the cursor as always
+ expect(complete).toEqual([])
+ expect(partial).toEqual([])
+ expect(off.map((request) => request.query)).toEqual(["DROP TABLE trades"])
+ })
+
+ it("resolves every selected statement beyond the viewport parsing window", () => {
+ // Given a document and selection extending beyond the viewport cache
+ const statementCount = 1_200
+ const statements = Array.from(
+ { length: statementCount },
+ (_, index) => `SELECT ${index + 1};`,
+ )
+ const editor = makeMultiLineEditor(statements.join("\n"), {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: statementCount,
+ endColumn: statements[statementCount - 1].length + 1,
+ })
+
+ // When resolving from offsets produced by the same full parse
+ const result = getQueriesToRun(
+ editor,
+ getStatementOffsets(editor),
+ "complete",
+ { queryOffsetsAreFresh: true },
+ )
+
+ // Then every selected statement is returned without another parse
+ expect(result).toHaveLength(statementCount)
+ expect(result[0].query).toBe("SELECT 1")
+ expect(result[statementCount - 1].query).toBe("SELECT 1200")
+ })
+
+ it("reparses cached offsets that became stale after an edit", () => {
+ // Given cached offsets followed by a length-changing edit
+ const staleOffsets = getStatementOffsets(
+ makeMultiLineEditor("SELECT 1;SELECT 2;SELECT 3;"),
+ )
+ const text = "SELECT 1; SELECT 2;SELECT 3;"
+ const editor = makeMultiLineEditor(text, {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 1,
+ endColumn: text.length + 1,
+ })
+
+ // When resolving the preview from those cached offsets
+ const result = getQueriesToRun(editor, staleOffsets, "partial")
+
+ // Then statement boundaries are reparsed instead of sliced mid-token
+ expect(
+ result.map((request) => request.selection?.queryText ?? request.query),
+ ).toEqual(["SELECT 1", "SELECT 2", "SELECT 3"])
+ })
})
describe("getQueryRequestFromEditor", () => {
- it("builds a selection request when enabled", () => {
- // Given the first statement is selected and running with selection is enabled
+ it("builds a selection request in partial mode", () => {
+ // Given the first statement is selected in partial mode
const editor = makeSingleLineEditor(TEXT, 3, FIRST_STATEMENT_SELECTION)
// When building the request from the editor
- const request = getQueryRequestFromEditor(editor, true)
+ const request = getQueryRequestFromEditor(editor, "partial")
// Then the request carries the selection
expect(request?.selection).toBeDefined()
})
- it("ignores the selection and uses the cursor query when disabled", () => {
- // Given the first statement is selected but running with selection is disabled
+ it("builds a whole-query request from a partial selection in complete mode", () => {
+ // Given a selection covering part of the first statement in complete mode
+ const editor = makeSingleLineEditor(
+ TEXT,
+ 3,
+ PARTIAL_FIRST_STATEMENT_SELECTION,
+ )
+
+ // When building the request from the editor
+ const request = getQueryRequestFromEditor(editor, "complete")
+
+ // Then the request holds the full statement with no selection
+ expect(request?.query).toBe("SELECT 11")
+ expect(request?.selection).toBeUndefined()
+ })
+
+ it("keeps a selection ending in a comment attached to the preceding statement", () => {
+ const editor = makeSingleLineEditor("select 1; /* note */ select 2;", 3, {
+ startColumn: 1,
+ endColumn: 21,
+ })
+
+ const request = getQueryRequestFromEditor(editor, "complete")
+
+ expect(request?.query).toBe("select 1")
+ expect(request?.selection).toBeUndefined()
+ })
+
+ it("ignores the selection and uses the cursor query when off", () => {
+ // Given the first statement is selected but the mode is off
const editor = makeSingleLineEditor(TEXT, 3, FIRST_STATEMENT_SELECTION)
// When building the request from the editor
- const request = getQueryRequestFromEditor(editor, false)
+ const request = getQueryRequestFromEditor(editor, "off")
// Then the request has no selection
expect(request?.selection).toBeUndefined()
@@ -439,6 +713,237 @@ describe("run with selection gating", () => {
})
})
+describe("resolveSelectionRun", () => {
+ const TEXT = "SELECT 11; SELECT 22"
+ const FRAGMENT_SELECTION = { startColumn: 4, endColumn: 10 }
+
+ it("reports no selection when the mode is off", () => {
+ // Given a real selection but the mode is off
+ const editor = makeSingleLineEditor(TEXT, 3, FRAGMENT_SELECTION)
+
+ // When resolving the selection run
+ // Then the selection is ignored
+ expect(resolveSelectionRun(editor, "off")).toEqual({
+ kind: "no-selection",
+ })
+ })
+
+ it("reports no selection for a collapsed cursor", () => {
+ // Given a collapsed selection (a bare cursor)
+ const editor = makeSingleLineEditor(TEXT, 3, {
+ startColumn: 3,
+ endColumn: 3,
+ })
+
+ // When resolving in partial and complete modes
+ // Then neither treats the cursor as a selection
+ expect(resolveSelectionRun(editor, "partial")).toEqual({
+ kind: "no-selection",
+ })
+ expect(resolveSelectionRun(editor, "complete")).toEqual({
+ kind: "no-selection",
+ })
+ })
+
+ it("runs the fragment in partial mode", () => {
+ // Given a fragment of the first statement selected in partial mode
+ const editor = makeSingleLineEditor(TEXT, 3, FRAGMENT_SELECTION)
+
+ // When resolving the selection run
+ // Then the fragment itself is the sql
+ expect(resolveSelectionRun(editor, "partial")).toEqual({
+ kind: "run",
+ sql: "ECT 11",
+ })
+ })
+
+ it("blocks a comment-only selection in partial mode", () => {
+ // Given only a line comment is selected
+ const text = "SELECT 1;\n-- a note"
+ const editor = makeMultiLineEditor(text, {
+ startLineNumber: 2,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 10,
+ })
+
+ // When resolving the selection run
+ // Then there is nothing to run and the run is blocked
+ expect(resolveSelectionRun(editor, "partial")).toEqual({
+ kind: "no-query",
+ })
+ })
+
+ it("blocks a whitespace-only selection", () => {
+ // Given a selection covering the indentation of a statement, with the
+ // cursor still inside that statement
+ const text = "SELECT\n 1\nFROM long_sequence(1)"
+ const editor = makeMultiLineEditor(text, {
+ startLineNumber: 2,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 4,
+ })
+
+ // When resolving the selection run in both honouring modes
+ // Then neither falls back to the cursor statement — the run is blocked
+ expect(resolveSelectionRun(editor, "partial")).toEqual({
+ kind: "no-query",
+ })
+ expect(resolveSelectionRun(editor, "complete")).toEqual({
+ kind: "no-query",
+ })
+ })
+
+ it.each([
+ ["a statement separator", { startColumn: 10, endColumn: 11 }],
+ ["a separator and whitespace", { startColumn: 10, endColumn: 12 }],
+ ])("blocks a selection of only %s", (_label, selection) => {
+ // Given a selection covering just the trailing semicolon
+ const editor = makeSingleLineEditor(TEXT, 3, selection)
+
+ // When resolving the selection run
+ // Then it carries no statement content, so the run is blocked
+ expect(resolveSelectionRun(editor, "partial")).toEqual({
+ kind: "no-query",
+ })
+ })
+
+ it("expands a cross-statement fragment in complete mode", () => {
+ // Given a selection cutting into both statements in complete mode
+ const editor = makeSingleLineEditor(TEXT, 3, {
+ startColumn: 8,
+ endColumn: 16,
+ })
+
+ // When resolving the selection run
+ // Then both whole statements join into one run
+ expect(resolveSelectionRun(editor, "complete")).toEqual({
+ kind: "run",
+ sql: "SELECT 11\n;\nSELECT 22",
+ })
+ })
+
+ it("blocks a comment-only selection in complete mode", () => {
+ // Given only the trailing comment line is selected in complete mode
+ const text = "SELECT 1;\n-- a note"
+ const editor = makeMultiLineEditor(text, {
+ startLineNumber: 2,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 10,
+ })
+
+ // When resolving the selection run
+ // Then no statement is touched and the run is blocked
+ expect(resolveSelectionRun(editor, "complete")).toEqual({
+ kind: "no-query",
+ })
+ })
+
+ it("blocks a comment-only selection between statements in complete mode", () => {
+ // Given a comment line between two statements, fully selected
+ const text = "SELECT 1;\n-- old: DELETE FROM t\nSELECT 2;"
+ const editor = makeMultiLineEditor(text, {
+ startLineNumber: 2,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 22,
+ })
+
+ // When resolving the selection run in complete mode
+ // Then it does not expand into either neighbouring statement
+ expect(resolveSelectionRun(editor, "complete")).toEqual({
+ kind: "no-query",
+ })
+ })
+})
+
+describe("statement boundaries", () => {
+ it("accepts a match that covers a complete statement", () => {
+ const text = "SELECT 1; SELECT 2;"
+ const startColumn = text.indexOf("SELECT 2") + 1
+ const editor = makeSingleLineEditor(text, startColumn, null)
+
+ expect(
+ isFullQueryMatch(
+ editor,
+ {
+ startLineNumber: 1,
+ startColumn,
+ endLineNumber: 1,
+ endColumn: text.length + 1,
+ },
+ getStatementOffsets(editor),
+ ),
+ ).toBe(true)
+ })
+
+ it("accepts complete statements followed by comments", () => {
+ const text = "SELECT 1;\n-- shared note"
+ const editor = makeMultiLineEditor(text)
+
+ expect(
+ isFullQueryMatch(
+ editor,
+ {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 2,
+ endColumn: 15,
+ },
+ getStatementOffsets(editor),
+ ),
+ ).toBe(true)
+ })
+
+ it("accepts a match spanning multiple complete statements", () => {
+ const text = "SELECT 1;\n\nSELECT 2;"
+ const editor = makeMultiLineEditor(text)
+
+ expect(
+ isFullQueryMatch(
+ editor,
+ {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 3,
+ endColumn: 10,
+ },
+ getStatementOffsets(editor),
+ ),
+ ).toBe(true)
+ })
+
+ it("rejects a match contained inside a larger statement", () => {
+ const text = "SELECT 1 UNION ALL SELECT 2;"
+ const startColumn = text.indexOf("SELECT 2") + 1
+ const editor = makeSingleLineEditor(text, startColumn, null)
+
+ expect(
+ isFullQueryMatch(
+ editor,
+ {
+ startLineNumber: 1,
+ startColumn,
+ endLineNumber: 1,
+ endColumn: text.length + 1,
+ },
+ getStatementOffsets(editor),
+ ),
+ ).toBe(false)
+ })
+
+ it("calculates offsets for multiline statements", () => {
+ const editor = makeMultiLineEditor("SELECT\n 11;\nSELECT\n 22;")
+
+ expect(getStatementOffsets(editor)).toEqual([
+ { startOffset: 0, endOffset: 11 },
+ { startOffset: 13, endOffset: 24 },
+ ])
+ })
+})
+
describe("isQueryTextAtOffset", () => {
it("matches when the query is still at its offset", () => {
// Given a buffer holding the query at a known offset
diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts
index 5a5ca1dea..8fa026fd3 100644
--- a/src/scenes/Editor/Monaco/utils.ts
+++ b/src/scenes/Editor/Monaco/utils.ts
@@ -26,6 +26,7 @@ import type { Monaco } from "@monaco-editor/react"
import type { ErrorResult } from "../../../utils"
import { hashString } from "../../../utils"
import type { ValidateQueryResult } from "../../../utils/questdb"
+import type { RunWithSelectionMode } from "../../../providers/LocalStorageProvider/types"
type IStandaloneCodeEditor = editor.IStandaloneCodeEditor
@@ -108,6 +109,9 @@ export const stripSQLComments = (text: string): string =>
return match
})
+export const hasRunnableSelectionContent = (text: string): boolean =>
+ /[^\s;]/.test(stripSQLComments(text))
+
export const getQueriesFromText = (text: string): string[] => {
if (!text || !stripSQLComments(text)) return []
@@ -138,25 +142,28 @@ export const getSelectedText = (
export const getQueriesToRun = (
editor: IStandaloneCodeEditor,
queryOffsets: { startOffset: number; endOffset: number }[],
- runWithSelection: boolean,
+ selectionMode: RunWithSelectionMode,
+ options?: { queryOffsetsAreFresh?: boolean },
): Request[] => {
const model = editor.getModel()
if (!model) return []
const selection = editor.getSelection()
const selectedText = selection ? model.getValueInRange(selection) : undefined
- if (!runWithSelection || !selection || !selectedText) {
+ if (selectionMode === "off" || !selection || !selectedText) {
const queryInCursor = getQueryFromCursor(editor)
if (queryInCursor) {
return [queryInCursor]
}
return []
}
+ if (!hasRunnableSelectionContent(selectedText)) {
+ return []
+ }
+ const normalizedSelectedText = normalizeQueryText(selectedText)
let selectionStartOffset = model.getOffsetAt(selection.getStartPosition())
let selectionEndOffset = model.getOffsetAt(selection.getEndPosition())
- const normalizedSelectedText = normalizeQueryText(selectedText)
-
if (stripSQLComments(normalizedSelectedText).length > 0) {
selectionStartOffset += selectedText.indexOf(normalizedSelectedText)
selectionEndOffset = selectionStartOffset + normalizedSelectedText.length
@@ -173,11 +180,40 @@ export const getQueriesToRun = (
return []
}
- const queries = getQueriesInRange(
- editor,
- model.getPositionAt(firstQueryOffsets.startOffset),
- model.getPositionAt(lastQueryOffsets.endOffset),
- )
+ // The lookups invert when the selection sits entirely in the gap between
+ // two statements (a comment line, blank lines) — no statement is touched.
+ if (firstQueryOffsets.startOffset > lastQueryOffsets.endOffset) {
+ return []
+ }
+
+ const queries = options?.queryOffsetsAreFresh
+ ? queryOffsets
+ .slice(
+ queryOffsets.indexOf(firstQueryOffsets),
+ queryOffsets.lastIndexOf(lastQueryOffsets) + 1,
+ )
+ .map(({ startOffset, endOffset }) => {
+ const startPosition = model.getPositionAt(startOffset)
+ const endPosition = model.getPositionAt(endOffset)
+
+ return {
+ query: model.getValueInRange({
+ startLineNumber: startPosition.lineNumber,
+ startColumn: startPosition.column,
+ endLineNumber: endPosition.lineNumber,
+ endColumn: endPosition.column,
+ }),
+ row: startPosition.lineNumber - 1,
+ column: startPosition.column,
+ endRow: endPosition.lineNumber - 1,
+ endColumn: endPosition.column,
+ }
+ })
+ : getQueriesInRange(
+ editor,
+ model.getPositionAt(firstQueryOffsets.startOffset),
+ model.getPositionAt(lastQueryOffsets.endOffset),
+ )
const requests = queries.map((query) => {
const clampedSelection = clampRange(model, selection, {
startOffset: model.getOffsetAt({
@@ -190,13 +226,13 @@ export const getQueriesToRun = (
}),
})
const clampedSelectionText = model.getValueInRange(clampedSelection)
- return stripSQLComments(normalizeQueryText(clampedSelectionText))
- ? {
- query: query.query,
- row: query.row,
- column: query.column,
- endRow: query.endRow,
- endColumn: query.endColumn,
+ if (!stripSQLComments(normalizeQueryText(clampedSelectionText))) {
+ return undefined
+ }
+ return selectionMode === "complete"
+ ? query
+ : {
+ ...query,
selection: {
startOffset: model.getOffsetAt({
lineNumber: clampedSelection.startLineNumber,
@@ -209,7 +245,6 @@ export const getQueriesToRun = (
queryText: clampedSelectionText,
},
}
- : undefined
})
return requests.filter(Boolean) as Request[]
}
@@ -649,6 +684,24 @@ export const getQueriesInRange = (
return [...stackQueries, ...(nextSqlQuery ? [nextSqlQuery] : [])]
}
+export const getStatementOffsets = (
+ editor: IStandaloneCodeEditor,
+): { startOffset: number; endOffset: number }[] => {
+ const model = editor.getModel()
+ if (!model) return []
+
+ return getAllQueries(editor).map((query) => ({
+ startOffset: model.getOffsetAt({
+ lineNumber: query.row + 1,
+ column: query.column,
+ }),
+ endOffset: model.getOffsetAt({
+ lineNumber: query.endRow + 1,
+ column: query.endColumn,
+ }),
+ }))
+}
+
export const getQueriesStartingFromLine = (
editor: IStandaloneCodeEditor,
lineNumber: number,
@@ -726,16 +779,22 @@ export const getQueryFromSelection = (
export const getQueryRequestFromEditor = (
editor: IStandaloneCodeEditor,
- runWithSelection: boolean,
+ selectionMode: RunWithSelectionMode,
): Request | undefined => {
let request: Request | undefined
const selectedText = getSelectedText(editor)
- const strippedNormalizedSelectedText = selectedText
- ? stripSQLComments(normalizeQueryText(selectedText))
- : undefined
- if (runWithSelection && strippedNormalizedSelectedText) {
- request = getQueryFromSelection(editor)
+ if (
+ selectionMode !== "off" &&
+ selectedText &&
+ hasRunnableSelectionContent(selectedText)
+ ) {
+ request =
+ selectionMode === "complete"
+ ? getQueriesToRun(editor, getStatementOffsets(editor), "complete", {
+ queryOffsetsAreFresh: true,
+ })[0]
+ : getQueryFromSelection(editor)
} else {
request = getQueryFromCursor(editor)
}
@@ -975,9 +1034,92 @@ export const normalizeQueryText = (query: string) => {
return result.trim()
}
+export const joinQueryTexts = (queries: string[]): string =>
+ queries.join("\n;\n")
+
+export type SelectionRunResolution =
+ | { kind: "no-selection" }
+ | { kind: "no-query" }
+ | { kind: "run"; sql: string }
+
+export const resolveSelectionRun = (
+ editor: IStandaloneCodeEditor,
+ selectionMode: RunWithSelectionMode,
+): SelectionRunResolution => {
+ if (selectionMode === "off") return { kind: "no-selection" }
+ const selection = editor.getSelection()
+ const model = editor.getModel()
+ if (!selection || !model || selection.isEmpty()) {
+ return { kind: "no-selection" }
+ }
+
+ const selectedText = model.getValueInRange(selection)
+ if (!hasRunnableSelectionContent(selectedText)) return { kind: "no-query" }
+
+ if (selectionMode === "complete") {
+ const queries = getQueriesToRun(
+ editor,
+ getStatementOffsets(editor),
+ "complete",
+ { queryOffsetsAreFresh: true },
+ )
+ if (queries.length === 0) return { kind: "no-query" }
+ return {
+ kind: "run",
+ sql: joinQueryTexts(
+ queries.map((request) => normalizeQueryText(request.query)),
+ ),
+ }
+ }
+
+ return { kind: "run", sql: normalizeQueryText(selectedText) }
+}
+
export const findMatches = (model: editor.ITextModel, needle: string) =>
model.findMatches(needle, true, false, true, null, true) ?? null
+export const isFullQueryMatch = (
+ editor: IStandaloneCodeEditor,
+ range: IRange,
+ statementOffsets: { startOffset: number; endOffset: number }[],
+): boolean => {
+ const model = editor.getModel()
+ if (!model) return false
+
+ const matchStartOffset = model.getOffsetAt({
+ lineNumber: range.startLineNumber,
+ column: range.startColumn,
+ })
+ const matchEndOffset = model.getOffsetAt({
+ lineNumber: range.endLineNumber,
+ column: range.endColumn,
+ })
+ const queryRanges = statementOffsets.filter(
+ ({ startOffset, endOffset }) =>
+ startOffset < matchEndOffset && endOffset > matchStartOffset,
+ )
+
+ if (queryRanges.length === 0) return false
+
+ const firstQuery = queryRanges[0]
+ const lastQuery = queryRanges[queryRanges.length - 1]
+ if (
+ matchStartOffset !== firstQuery.startOffset ||
+ matchEndOffset < lastQuery.endOffset
+ ) {
+ return false
+ }
+
+ const trailingStart = model.getPositionAt(lastQuery.endOffset)
+ const trailingText = model.getValueInRange({
+ startLineNumber: trailingStart.lineNumber,
+ startColumn: trailingStart.column,
+ endLineNumber: range.endLineNumber,
+ endColumn: range.endColumn,
+ })
+ return getQueriesFromText(trailingText).length === 0
+}
+
export const getLastPosition = (
editor: IStandaloneCodeEditor,
): IPosition | undefined => {
diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx
index 6adc0367c..7ef84f11c 100644
--- a/src/scenes/Editor/Notebook/NotebookProvider.tsx
+++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx
@@ -621,7 +621,7 @@ export const NotebookProvider: React.FC<{
!cell.bottomResized
) {
store.updateCell(cellId, {
- bottomHeight: computeResultBottomHeight(cell.result),
+ bottomHeight: computeResultBottomHeight(cell.result, cell.value),
})
}
diff --git a/src/scenes/Editor/Notebook/cells/Cell.tsx b/src/scenes/Editor/Notebook/cells/Cell.tsx
index 88fe7d161..b5ae87894 100644
--- a/src/scenes/Editor/Notebook/cells/Cell.tsx
+++ b/src/scenes/Editor/Notebook/cells/Cell.tsx
@@ -245,7 +245,7 @@ const CellInner: React.FC = ({
(state) => updateCell(cell.id, { editorViewState: state }),
[cell.id, updateCell],
),
- onRunAtCursor: () => runSingle(),
+ onRunAtCursor: () => runSingleFromEditor(),
onRunAll: () => runAll(),
onContentHeightChange: handleContentHeightChange,
validate: validateWithGlobals,
@@ -281,16 +281,21 @@ const CellInner: React.FC = ({
editorRef.current?.getContentHeight() ?? null
}, [editorRef])
- const { runAll, runSingle, handleDrawClick, isGridLoading } =
- useCellRunActions({
- cell,
- isRunning,
- isCompactTier,
- showBottomSlot,
- editorRef,
- applyHighlight,
- clearHighlight,
- })
+ const {
+ runAll,
+ runSingleFromEditor,
+ runSingleFromResult,
+ handleDrawClick,
+ isGridLoading,
+ } = useCellRunActions({
+ cell,
+ isRunning,
+ isCompactTier,
+ showBottomSlot,
+ editorRef,
+ applyHighlight,
+ clearHighlight,
+ })
const isExternalSyncRef = useRef(false)
@@ -372,13 +377,19 @@ const CellInner: React.FC = ({
// Focus inside Monaco: its own action handles the key (same resolver), so
// bail to avoid running twice.
if (editorContainerRef.current?.contains(document.activeElement)) return
- e.preventDefault()
- if (e.shiftKey) runAll()
- else runSingle()
+ if (e.shiftKey) {
+ e.preventDefault()
+ runAll()
+ return
+ }
+ if (resultRef.current?.contains(document.activeElement)) {
+ e.preventDefault()
+ runSingleFromResult()
+ }
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
- }, [isFocused, isMaximized, runAll, runSingle])
+ }, [isFocused, isMaximized, runAll, runSingleFromResult])
const cellEl = (
= ({
}) => (
= ({
}
onRun()
}}
- aria-label={runActive ? "Hide result" : "Run"}
+ aria-label={runActive ? "Hide result" : "Run cell"}
>
{showLabels && "Run"}
diff --git a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts
index 53fc2985f..06085116b 100644
--- a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts
+++ b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts
@@ -5,7 +5,12 @@ import { useNotebookActions, useNotebookBufferId } from "../NotebookProvider"
import { useCellRefresh } from "../cellRefresh/CellRefreshContext"
import { useLocalStorage } from "../../../../providers/LocalStorageProvider"
import { useValidateWithGlobals } from "../globals/useValidateWithGlobals"
-import { getQueryFromCursor, normalizeQueryText } from "../../Monaco/utils"
+import {
+ getQueryFromCursor,
+ normalizeQueryText,
+ resolveSelectionRun,
+ type SelectionRunResolution,
+} from "../../Monaco/utils"
import { resolveActiveStatementSql, resolveRunAction } from "../notebookUtils"
import {
emitUserAction,
@@ -29,6 +34,10 @@ type Options = {
clearHighlight: () => void
}
+type SingleRunSource = "editor" | "result"
+
+type RunRequest = { kind: "all" } | { kind: "single"; source: SingleRunSource }
+
// Run / draw orchestration for a cell: resolves what a Run gesture means for
// the current mode and view, runs it, and emits the agent-facing events. The
// toolbar buttons, Monaco commands, keyboard shortcuts, and the RUN/DRAW
@@ -51,7 +60,7 @@ export const useCellRunActions = ({
} = useNotebookActions()
const bufferIdForEvents = useNotebookBufferId()
const validateWithGlobals = useValidateWithGlobals()
- const { runWithSelection } = useLocalStorage()
+ const { runWithSelectionMode } = useLocalStorage()
const isDrawMode = cell.mode === "draw"
// A run from the Run toggle spins the Run segment; a run from the refresh
@@ -108,25 +117,21 @@ export const useCellRunActions = ({
validateWithGlobals,
])
- const tryRunSelection = useCallback(async (): Promise => {
- if (!runWithSelection) return false
+ const tryRunSelection = useCallback((): SelectionRunResolution["kind"] => {
const ed = editorRef.current
- if (!ed) return false
- const selection = ed.getSelection()
- const model = ed.getModel()
- if (!selection || !model || selection.isEmpty()) return false
-
- const selectedText = model.getValueInRange(selection)
- const normalized = normalizeQueryText(selectedText)
- if (!normalized) return false
-
+ if (!ed) return "no-selection"
+ const resolution = resolveSelectionRun(ed, runWithSelectionMode)
+ if (resolution.kind === "no-selection") return "no-selection"
clearHighlight()
+ if (resolution.kind === "no-query") return "no-query"
+
void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RUN)
- const { ok } = await runCell(cell.id, normalized)
- applyHighlight(ok)
- return true
+ void runCell(cell.id, resolution.sql).then(({ ok }) => {
+ if (runWithSelectionMode === "partial") applyHighlight(ok)
+ })
+ return "run"
}, [
- runWithSelection,
+ runWithSelectionMode,
cell.id,
runCell,
editorRef,
@@ -147,22 +152,60 @@ export const useCellRunActions = ({
[bufferIdForEvents, cell.id],
)
- const handleRunAll = useCallback(
- async (ignoreSelection = false) => {
- if (editorRef.current) {
- if (!ignoreSelection && (await tryRunSelection())) return
- clearHighlight()
+ const handleRunAll = useCallback(async () => {
+ if (editorRef.current) clearHighlight()
+
+ const priorResult =
+ getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
+ const { ok } = await runCell(cell.id)
+ const freshResult =
+ getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
+ emitRanEvent(createRunStatus(priorResult, freshResult, ok))
+ }, [
+ cell.id,
+ runCell,
+ editorRef,
+ clearHighlight,
+ emitRanEvent,
+ getCellsSnapshot,
+ ])
+
+ const handleRunSingle = useCallback(
+ (source: SingleRunSource): boolean => {
+ let sql: string | undefined
+ if (source === "editor") {
+ const ed = editorRef.current
+ if (!ed) return false
+ const cursorQuery = getQueryFromCursor(ed)?.query
+ const selectionRun = tryRunSelection()
+ if (selectionRun === "run") return true
+ if (selectionRun === "no-query") {
+ toast.error("Nothing to run")
+ return false
+ }
+ sql = cursorQuery
+ } else {
+ sql = resolveActiveStatementSql(cell.value, cell.result)
}
+ if (!sql?.trim()) {
+ toast.error("Nothing to run")
+ return false
+ }
+ clearHighlight()
const priorResult =
getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
- const { ok } = await runCell(cell.id)
- const freshResult =
- getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
- emitRanEvent(createRunStatus(priorResult, freshResult, ok))
+ void runCell(cell.id, normalizeQueryText(sql)).then(({ ok }) => {
+ const freshResult =
+ getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
+ emitRanEvent(createRunStatus(priorResult, freshResult, ok))
+ })
+ return true
},
[
cell.id,
+ cell.value,
+ cell.result,
runCell,
tryRunSelection,
editorRef,
@@ -172,46 +215,11 @@ export const useCellRunActions = ({
],
)
- const handleRunSingle = useCallback(async () => {
- const ed = editorRef.current
- // Capture the cursor's statement before any await — a reveal in this same
- // gesture can unmount the editor, and reading it afterwards loses it.
- const cursorQuery = ed ? getQueryFromCursor(ed)?.query : undefined
- if (ed && (await tryRunSelection())) return
- // Cursor first; otherwise the active tab's statement — resolved from the
- // same frame the tabs render, so a "Not run" tab runs its own SQL and a
- // single run never silently expands into running every statement.
- const activeQuery = resolveActiveStatementSql(cell.value, cell.result)
- const sql = cursorQuery ?? activeQuery
- if (!sql?.trim()) {
- await handleRunAll()
- return
- }
- clearHighlight()
- const priorResult =
- getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
- const { ok } = await runCell(cell.id, normalizeQueryText(sql))
- const freshResult =
- getCellsSnapshot().find((c) => c.id === cell.id)?.result ?? null
- emitRanEvent(createRunStatus(priorResult, freshResult, ok))
- }, [
- cell.id,
- cell.value,
- cell.result,
- runCell,
- tryRunSelection,
- editorRef,
- clearHighlight,
- handleRunAll,
- emitRanEvent,
- getCellsSnapshot,
- ])
-
const runResolved = useCallback(
- (intent: "all" | "single", ignoreSelection = false) => {
+ (request: RunRequest) => {
const plan = resolveRunAction(
{ mode: cell.mode, result: cell.result },
- { isCompactTier, showBottomSlot, intent },
+ { isCompactTier, showBottomSlot, intent: request.kind },
)
if (plan.kind === "noop") return
if (plan.kind === "chart") {
@@ -222,16 +230,22 @@ export const useCellRunActions = ({
})
return
}
- if (plan.exitDraw) {
+ const exitDrawMode = () => {
+ if (!plan.exitDraw) return
signalUserEdit(bufferIdForEvents)
setCellMode(cell.id, "run")
}
firstRunRef.current = cell.result == null
- // Start the run before revealing: under React 17 a reveal fired from a
- // native key event re-renders synchronously and unmounts the editor, so
- // the run must read the cursor first.
- if (plan.kind === "run-all") void handleRunAll(ignoreSelection)
- else void handleRunSingle()
+ if (request.kind === "all") {
+ exitDrawMode()
+ void handleRunAll()
+ if (plan.reveal) setCellViewMaximized(cell.id, true)
+ return
+ }
+ // A single run that finds nothing to run leaves the cell untouched —
+ // a draw cell keeps its chart instead of dropping to the grid.
+ if (!handleRunSingle(request.source)) return
+ exitDrawMode()
if (plan.reveal) setCellViewMaximized(cell.id, true)
},
[
@@ -247,8 +261,15 @@ export const useCellRunActions = ({
handleRunSingle,
],
)
- const runAll = useCallback(() => runResolved("all"), [runResolved])
- const runSingle = useCallback(() => runResolved("single"), [runResolved])
+ const runAll = useCallback(() => runResolved({ kind: "all" }), [runResolved])
+ const runSingleFromEditor = useCallback(
+ () => runResolved({ kind: "single", source: "editor" }),
+ [runResolved],
+ )
+ const runSingleFromResult = useCallback(
+ () => runResolved({ kind: "single", source: "result" }),
+ [runResolved],
+ )
const cellRefresh = useCellRefresh()
// Refresh on a classified non-write grid routes to the engine: every
// statement refreshes in parallel while the old rows stay visible. A write
@@ -274,7 +295,7 @@ export const useCellRunActions = ({
})
return
}
- runResolved("all", true)
+ runResolved({ kind: "all" })
}, [cell.id, cell.mode, cell.result, cellRefresh, emitRanEvent, runResolved])
useEffect(() => {
@@ -305,5 +326,11 @@ export const useCellRunActions = ({
const isGridLoading = isRunning && firstRunRef.current
- return { runAll, runSingle, handleDrawClick, isGridLoading }
+ return {
+ runAll,
+ runSingleFromEditor,
+ runSingleFromResult,
+ handleDrawClick,
+ isGridLoading,
+ }
}
diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts
index deaa6f9b9..9ac85852d 100644
--- a/src/scenes/Editor/Notebook/notebookUtils.test.ts
+++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts
@@ -1358,11 +1358,17 @@ describe("computeResultBottomHeight", () => {
// ROW_HEIGHT = 30
// MAX_RESERVED_ROWS = 10
+ const heightForResult = (result: CellResult | null | undefined) =>
+ computeResultBottomHeight(
+ result,
+ (result?.results ?? []).map(({ query }) => query).join(";\n"),
+ )
+
it("null/undefined/empty result → notification-only", () => {
- expect(computeResultBottomHeight(null)).toBe(44)
- expect(computeResultBottomHeight(undefined)).toBe(44)
+ expect(heightForResult(null)).toBe(44)
+ expect(heightForResult(undefined)).toBe(44)
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [],
activeResultIndex: 0,
timestamp: 0,
@@ -1372,7 +1378,7 @@ describe("computeResultBottomHeight", () => {
it("single error → notification-only", () => {
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [{ type: "error", query: "X", error: "boom" }],
activeResultIndex: 0,
timestamp: 0,
@@ -1383,7 +1389,7 @@ describe("computeResultBottomHeight", () => {
it("single DDL/DML/notice → notification-only", () => {
for (const type of ["ddl", "dml"] as const) {
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [{ type, query: "X" }],
activeResultIndex: 0,
timestamp: 0,
@@ -1395,7 +1401,7 @@ describe("computeResultBottomHeight", () => {
it("single DQL with columns but 0 rows → notification + actions bar + header (no rows)", () => {
// The column headers show even with no rows: 44 + 36 + 44 + 0*30 = 124.
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [
{
type: "dql",
@@ -1413,7 +1419,7 @@ describe("computeResultBottomHeight", () => {
it("single DQL with no columns → notification-only", () => {
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [
{
type: "dql",
@@ -1444,19 +1450,77 @@ describe("computeResultBottomHeight", () => {
timestamp: 0,
})
// 1 row: 44 + 36 + 44 + 1*30 = 154
- expect(computeResultBottomHeight(make(1))).toBe(154)
+ expect(heightForResult(make(1))).toBe(154)
// 5 rows: 44 + 36 + 44 + 5*30 = 274
- expect(computeResultBottomHeight(make(5))).toBe(274)
+ expect(heightForResult(make(5))).toBe(274)
// 10 rows: 44 + 36 + 44 + 10*30 = 424
- expect(computeResultBottomHeight(make(10))).toBe(424)
+ expect(heightForResult(make(10))).toBe(424)
// 50 rows: cap at 10 → still 424
- expect(computeResultBottomHeight(make(50))).toBe(424)
+ expect(heightForResult(make(50))).toBe(424)
+ })
+
+ it("one executed DQL in a multi-statement cell adds tabs but tight-fits its rows", () => {
+ // The result array is compact (only SELECT 2 ran), but the rendered frame
+ // has two tabs: SELECT 1 is "Not run" and SELECT 2 owns the result.
+ const result = {
+ results: [
+ {
+ type: "dql" as const,
+ query: "select 2",
+ columns: [{ name: "2", type: "INT" }],
+ dataset: [[2]],
+ count: 1,
+ },
+ ],
+ activeResultIndex: 0,
+ timestamp: 0,
+ }
+
+ // 40 tab + 44 notification + 36 actions + 44 header + 1*30 row = 194.
+ expect(computeResultBottomHeight(result, "select 1;\nselect 2")).toBe(194)
+ })
+
+ it("keeps the grid block reserved while the active tab is an unexecuted statement", () => {
+ // Given a two-statement cell where only the second statement ran, and the
+ // user has selected the "Not run" tab, so the active slot holds no result
+ const result = {
+ results: [
+ {
+ type: "dql" as const,
+ query: "select 2",
+ columns: [{ name: "2", type: "INT" }],
+ dataset: [[2]],
+ count: 1,
+ },
+ ],
+ activeResultIndex: 0,
+ activeStatementKey: statementKeysFor(["select 1"])[0],
+ timestamp: 0,
+ }
+
+ // When sizing the bottom slot for that frame
+ // Then it still reserves the executed result's grid rather than collapsing
+ // to tab bar + notification (84), which would clip the visible grid.
+ expect(computeResultBottomHeight(result, "select 1;\nselect 2")).toBe(194)
+ })
+
+ it("one executed non-grid result in a multi-statement cell still includes its tabs", () => {
+ expect(
+ computeResultBottomHeight(
+ {
+ results: [{ type: "ddl", query: "create table x (n int)" }],
+ activeResultIndex: 0,
+ timestamp: 0,
+ },
+ "create table x (n int);\nselect * from x",
+ ),
+ ).toBe(84)
})
it("multi-statement, first DQL with rows → tab + notification + header + 10 rows", () => {
// 40 + 44 + 36 + 44 + 10*30 = 464
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [
{
type: "dql",
@@ -1473,10 +1537,11 @@ describe("computeResultBottomHeight", () => {
).toBe(464)
})
- it("multi-statement, first is error → tab + notification only (no grid to show)", () => {
- // 40 + 44 = 84
+ it("multi-statement with any DQL tab reserves the full grid block", () => {
+ // The first tab is an error, but the active second tab renders a grid.
+ // Reserve the same stable multi-tab height: 40 + 44 + 36 + 44 + 10*30.
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [
{ type: "error", query: "Q1", error: "boom" },
{
@@ -1490,14 +1555,14 @@ describe("computeResultBottomHeight", () => {
activeResultIndex: 1,
timestamp: 0,
}),
- ).toBe(84)
+ ).toBe(464)
})
it("multi-statement, first DQL with columns but 0 rows → tab + full grid block", () => {
// The first query shows its column headers, so we reserve the grid block
// like any DQL-first script: 40 + 44 + 36 + 44 + 10*30 = 464.
expect(
- computeResultBottomHeight({
+ heightForResult({
results: [
{
type: "dql",
@@ -2077,7 +2142,7 @@ describe("releaseCellResultPatch", () => {
expect(patch).toEqual({
result: undefined,
lastRunStatus: "success",
- bottomHeight: computeResultBottomHeight(threeRowResult),
+ bottomHeight: computeResultBottomHeight(threeRowResult, "select 1"),
})
})
diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts
index fd78e7b03..2ab42a702 100644
--- a/src/scenes/Editor/Notebook/notebookUtils.ts
+++ b/src/scenes/Editor/Notebook/notebookUtils.ts
@@ -1370,7 +1370,9 @@ export const releaseCellResultPatch = (
lastRunStatus: carriedRunStatus(cell),
lastRunError: carriedRunError(cell),
...(cell.mode !== "draw" && cell.bottomHeight == null && cell.result != null
- ? { bottomHeight: computeResultBottomHeight(cell.result) }
+ ? {
+ bottomHeight: computeResultBottomHeight(cell.result, cell.value),
+ }
: {}),
})
@@ -1380,7 +1382,10 @@ const isDqlWithColumns = (r: SingleQueryResult): boolean =>
const dqlRowCount = (r: SingleQueryResult): number =>
r.type === "dql" ? r.dataset.length : 0
-// Computes the bottom slot height for a result based on its content.
+// Computes the bottom slot height for the same statement frame rendered by
+// InlineResultTable. `value` is the cell's current SQL; a partial run can have
+// one result while still rendering multiple statement tabs (the unexecuted
+// statements appear as "Not run").
//
// Rules:
// 1. Single-statement, no grid (error / DDL / DML / notice): just the
@@ -1389,26 +1394,30 @@ const dqlRowCount = (r: SingleQueryResult): number =>
// header + min(N, 10) rows. A 0-row DQL still shows its column headers, so
// it reserves the header with no row space. Shrinks for small results,
// caps at 10 for large ones.
-// 3. Multi-statement (script) run:
-// - tab bar always visible.
-// - If the first result is non-DQL (error / DDL / DML / notice), height
-// is just tab bar + notification (no grid to show).
-// - Otherwise reserve a full 10 rows worth of space regardless of how
-// many rows the active tab actually has (avoids jitter when switching
-// between tabs that have different row counts).
+// 3. Multiple rendered statement slots add the tab bar, including when all
+// but one slot are "Not run".
+// 4. Multiple executed results reserve a full 10 rows whenever any result
+// has a DQL grid (avoids clipping and jitter when switching result tabs).
+// A single executed result still tight-fits its own row count.
export const computeResultBottomHeight = (
result: CellResult | null | undefined,
+ value: string,
): number => {
if (!result || result.results.length === 0) return NOTIFICATION_PX
- const isMulti = result.results.length > 1
- const tabBar = isMulti ? TAB_BAR_PX : 0
-
- if (isMulti) {
- const first = result.results[0]
- if (!first || !isDqlWithColumns(first)) {
- // First query failed / wasn't DQL — there is no grid to show, no point
- // reserving 10 rows of space. The tab bar still shows so the user can
- // click through other tabs.
+ const statements = getQueriesFromText(value)
+ const frame =
+ deriveStatementFrame(statements, result) ?? derivePositionalFrame(result)
+ if (!frame) return NOTIFICATION_PX
+ const slots = frame.slots
+ const hasMultipleTabs = slots.length > 1
+ const hasMultipleResults = result.results.length > 1
+ const tabBar = hasMultipleTabs ? TAB_BAR_PX : 0
+
+ if (hasMultipleResults) {
+ const hasGrid = slots.some(
+ (slot) => slot.result && isDqlWithColumns(slot.result),
+ )
+ if (!hasGrid) {
return tabBar + NOTIFICATION_PX
}
return (
@@ -1420,14 +1429,19 @@ export const computeResultBottomHeight = (
)
}
- // Single-statement: tight-fit up to 10 rows.
- const only = result.results[0]
+ // Single executed result: tight-fit up to 10 rows. The tab bar is still
+ // included when the editor contributes additional "Not run" slots.
+ const only = frame.slots[frame.activeSlotIndex]?.result ?? result.results[0]
if (!only || !isDqlWithColumns(only)) {
- return NOTIFICATION_PX
+ return tabBar + NOTIFICATION_PX
}
const rows = Math.min(MAX_RESERVED_ROWS, dqlRowCount(only))
return (
- NOTIFICATION_PX + RESULT_ACTIONS_BAR_PX + HEADER_HEIGHT + rows * ROW_HEIGHT
+ tabBar +
+ NOTIFICATION_PX +
+ RESULT_ACTIONS_BAR_PX +
+ HEADER_HEIGHT +
+ rows * ROW_HEIGHT
)
}
@@ -1438,7 +1452,7 @@ export const computeResultBottomHeight = (
export const defaultBottomHeightFor = (cell: NotebookCell): number =>
cell.mode === "draw"
? DEFAULT_CHART_BOTTOM_HEIGHT
- : computeResultBottomHeight(cell.result)
+ : computeResultBottomHeight(cell.result, cell.value)
// True iff this cell occupies vertical space for a bottom slot — i.e. its
// total height includes bottomHeight. This includes the chart-expanded case
@@ -1461,7 +1475,7 @@ export const modeChangeBottomHeightPatch = (
mode === "draw"
? DEFAULT_CHART_BOTTOM_HEIGHT
: cell?.result
- ? computeResultBottomHeight(cell.result)
+ ? computeResultBottomHeight(cell.result, cell.value)
: undefined,
}
}
@@ -1495,7 +1509,7 @@ export const patchCellRunResult = (
cell.mode !== "draw" &&
cell.type !== "markdown"
) {
- next.bottomHeight = computeResultBottomHeight(result)
+ next.bottomHeight = computeResultBottomHeight(result, cell.value)
}
return next
})
diff --git a/src/scenes/Editor/Notebook/result-table/TabBar.tsx b/src/scenes/Editor/Notebook/result-table/TabBar.tsx
index a6e79e291..7ed12364f 100644
--- a/src/scenes/Editor/Notebook/result-table/TabBar.tsx
+++ b/src/scenes/Editor/Notebook/result-table/TabBar.tsx
@@ -27,21 +27,21 @@ const truncateQuery = (query: string, maxLen = 30): string => {
const SlotIcon: React.FC<{ slot: StatementSlotView }> = ({ slot }) => {
if (slot.refreshing || slot.result?.type === "running") {
return (
-
+
)
}
if (slot.refreshError !== undefined) {
return (
-
+
)
}
if (slot.result === null) {
return (
-
+
)
@@ -49,20 +49,23 @@ const SlotIcon: React.FC<{ slot: StatementSlotView }> = ({ slot }) => {
const { type } = slot.result
if (type === "queued") {
return (
-
+
)
}
if (type === "cancelled") {
return (
-
+
)
}
return (
-
+
{type === "error" ? (
) : (