diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 4d12996f81..c49c9966f9 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -2212,9 +2212,15 @@ function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from event.preventDefault(); event.stopPropagation(); // Gutter play is always scoped to the statement/command for that line, even - // when the main editor execute action would run the full document. + // when the main editor execute action would run the full document. An explicit + // selection overlapping that statement is more specific, so preserve it; a + // selection elsewhere in the document must not hijack the click. const editorViewportRequestId = executionViewportOwnership.beginRequest(); - emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId }); + const selection = currentView.state.selection.main; + const hasSelectedSql = !selection.empty && currentView.state.sliceDoc(selection.from, selection.to).trim().length > 0; + const selectionOverlapsStatement = hasSelectedSql && selection.from < statementRange.to && statementRange.from < selection.to; + const executionSnapshot = selectionOverlapsStatement ? sqlExecutionSnapshotFromView(currentView) : sqlExecutionSnapshotForRange(currentView, statementRange); + emitExecutionRequest({ ...executionSnapshot, editorViewportRequestId }); // 不主动聚焦编辑器,否则 CodeMirror 会把屏幕滚回之前的光标位置。 // currentView.focus(); return true; diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts index c5520f4568..1e435c5a22 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts @@ -32,7 +32,10 @@ describe("QueryEditor execution routing", () => { expect(queryEditorSource).toContain("function executeFromContextMenu()"); expect(queryEditorSource).toContain("requestExecute();\n focusEditor();"); expect(queryEditorSource).toContain("function executeSqlStatementFromGutter"); - expect(queryEditorSource).toContain("emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId })"); + expect(queryEditorSource).toContain("const hasSelectedSql = !selection.empty && currentView.state.sliceDoc(selection.from, selection.to).trim().length > 0;"); + expect(queryEditorSource).toContain("const selectionOverlapsStatement = hasSelectedSql && selection.from < statementRange.to && statementRange.from < selection.to;"); + expect(queryEditorSource).toContain("const executionSnapshot = selectionOverlapsStatement ? sqlExecutionSnapshotFromView(currentView) : sqlExecutionSnapshotForRange(currentView, statementRange);"); + expect(queryEditorSource).toContain("emitExecutionRequest({ ...executionSnapshot, editorViewportRequestId })"); }); it("routes the new-result-tab shortcut through the same target selection contract", () => { @@ -97,7 +100,9 @@ describe("QueryEditor execution routing", () => { it("preserves the source range when executing from the statement gutter", () => { expect(queryEditorSource).toContain("const editorViewportRequestId = executionViewportOwnership.beginRequest()"); - expect(queryEditorSource).toContain("emitExecutionRequest({ ...sqlExecutionSnapshotForRange(currentView, statementRange), editorViewportRequestId })"); + expect(queryEditorSource).toContain("sqlExecutionSnapshotFromView(currentView)"); + expect(queryEditorSource).toContain("sqlExecutionSnapshotForRange(currentView, statementRange)"); + expect(queryEditorSource).toContain("emitExecutionRequest({ ...executionSnapshot, editorViewportRequestId })"); expect(queryEditorSource).not.toContain('emit("execute", statementRange.sql)'); }); diff --git a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts index d955afa972..61dcb2f9a0 100644 --- a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts @@ -221,6 +221,20 @@ LEFT JOIN ( expect(foldedTextAtLine(state, 3)).toBe("\n SELECT account_id, SUM(amount) AS total\n FROM fee\n GROUP BY account_id\n"); }); + it("folds multiline parenthesized IN lists while keeping the surrounding SQL visible", () => { + const sql = `SELECT * +FROM account +WHERE account_id IN ( + '7687402546616451098', + '7687402818521628718', + '7687402206655496235' +) +AND status = 'active';`; + const state = stateFor(sql); + + expect(foldedTextAtLine(state, 3)).toBe("\n '7687402546616451098',\n '7687402818521628718',\n '7687402206655496235'\n"); + }); + it("folds SELECT branches around UNION ALL at the same query level", () => { const sql = `WITH combined AS ( SELECT id, amount diff --git a/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts b/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts index e5cf433d36..567db8facd 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts @@ -15,7 +15,8 @@ interface FoldRange { // `@codemirror/lang-sql`'s grammar is deliberately shallow (see its `foldNodeProp`, which only // covers the flat `Statement` and `BlockComment` nodes). This service adds structure-aware folds -// for procedural blocks and query expressions without replacing CodeMirror's native folds. +// for procedural blocks and parenthesized query expressions without replacing CodeMirror's native +// folds. // // Scoped to `BEGIN...END` and `CASE...END` (issue #6574) -- `BEGIN TRY`/`END TRY` and // `BEGIN CATCH`/`END CATCH` fall out of this for free, since `BEGIN` is matched regardless of a @@ -94,16 +95,6 @@ function queryScopeEnd(state: EditorState, scope: SyntaxNode): number { return end; } -function isQueryParens(state: EditorState, node: SyntaxNode): boolean { - for (let child = node.firstChild; child; child = child.nextSibling) { - if (child.name === "(" || child.name === "LineComment" || child.name === "BlockComment") continue; - if (child.name !== "Keyword") return false; - const keyword = state.sliceDoc(child.from, child.to).toUpperCase(); - return keyword === "SELECT" || keyword === "WITH"; - } - return false; -} - interface QueryScopeTokens { scope: SyntaxNode; tokens: Array<{ from: number; keyword: "SELECT" | "UNION" }>; @@ -131,7 +122,10 @@ function addQueryStructureFoldRanges(state: EditorState, tree: Tree, ranges: Map const scopes = new Map(); tree.iterate({ enter(node) { - if (node.name === "Parens" && isQueryParens(state, node.node)) { + // Fold any multiline parenthesized expression, including large `IN (...)` + // value lists. The opening line remains visible so the surrounding SQL + // structure is still readable while the contents can be collapsed. + if (node.name === "Parens") { addMultilineFoldRange(state, ranges, node.from, queryScopeEnd(state, node.node)); } if (node.name !== "Keyword") return; diff --git a/docs/assets/sql-editor-fold-execution.png b/docs/assets/sql-editor-fold-execution.png new file mode 100644 index 0000000000..87e02fae2b Binary files /dev/null and b/docs/assets/sql-editor-fold-execution.png differ