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 && (