From fe955da72958900a4071f4372c84f8dca2bf1b63 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 4 Sep 2026 00:36:15 +0800 Subject: [PATCH] fix(editor): fold Elasticsearch REST requests individually Closes #8065 --- .../codemirrorSqlBlockFolding.spec.ts | 65 ++++++++++++++++++- .../lib/editor/codemirrorSqlBlockFolding.ts | 47 +++++++++++--- 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts index 4593b65a6c..d955afa972 100644 --- a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts @@ -3,12 +3,12 @@ import { ensureSyntaxTree, foldable } from "@codemirror/language"; import { Compartment, EditorState } from "@codemirror/state"; import { describe, expect, it } from "vitest"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; -import { collectUnionBranchFoldRanges, sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; +import { collectUnionBranchFoldRanges, createSqlBlockFoldService, sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; -function stateFor(doc: string, dialectName: "mysql" | "postgres" | "sqlserver" = "mysql"): EditorState { +function stateFor(doc: string, dialectName: "mysql" | "postgres" | "sqlserver" = "mysql", folding = sqlBlockFoldService): EditorState { const state = EditorState.create({ doc, - extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialectName) }), sqlBlockFoldService], + extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialectName) }), folding], }); ensureSyntaxTree(state, doc.length, 5_000); return state; @@ -21,6 +21,65 @@ function foldedTextAtLine(state: EditorState, lineNumber: number): string | null } describe("sqlBlockFoldService", () => { + it.each(["elasticsearch", "easysearch", "meilisearch"] as const)("folds one %s REST request without a semicolon", (databaseType) => { + const sql = `POST /orders/_search +{ + "query": { "match_all": {} } +}`; + const state = stateFor(sql, "mysql", createSqlBlockFoldService(databaseType)); + + expect(sql).not.toContain(";"); + expect(foldedTextAtLine(state, 1)).toBe('\n{\n "query": { "match_all": {} }\n}'); + }); + + it("folds multiple REST requests independently", () => { + const sql = `POST /orders/_search +{ + "query": { "match_all": {} } +} + +GET /orders/_count +{ + "query": { "term": { "status": "paid" } } +}`; + const state = stateFor(sql); + + expect(sql).not.toContain(";"); + expect(foldedTextAtLine(state, 1)).toBe('\n{\n "query": { "match_all": {} }\n}'); + expect(foldedTextAtLine(state, 6)).toBe('\n{\n "query": { "term": { "status": "paid" } }\n}'); + }); + + it("keeps REST request comments and blank lines outside individual folds", () => { + const sql = `# first request +POST /orders/_search +{ + "query": { "match_all": {} } +} + +// second request +POST /orders/_search +{ + "query": { "term": { "status": "paid" } } +}`; + const state = stateFor(sql); + + expect(foldedTextAtLine(state, 2)).toBe('\n{\n "query": { "match_all": {} }\n}'); + expect(foldedTextAtLine(state, 8)).toBe('\n{\n "query": { "term": { "status": "paid" } }\n}'); + }); + + it("keeps ordinary SQL block and CASE folding unchanged", () => { + const sql = `BEGIN + SELECT CASE + WHEN status = 'paid' THEN 1 + ELSE 0 + END; +END`; + const state = stateFor(sql); + + expect(foldedTextAtLine(state, 1)).toContain("SELECT CASE"); + expect(foldedTextAtLine(state, 2)).toBe("\n WHEN status = 'paid' THEN 1\n ELSE 0\n "); + }); + it("folds a BEGIN...END block, from the end of the BEGIN line to the start of END", () => { const sql = "CREATE PROCEDURE p()\nBEGIN\n SELECT 1;\nEND"; const state = stateFor(sql); diff --git a/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts b/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts index 8ee0f00d60..e5cf433d36 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts @@ -1,5 +1,7 @@ import { foldService, syntaxTree } from "@codemirror/language"; import type { EditorState } from "@codemirror/state"; +import { elasticsearchRestRequestRanges } from "@/lib/sql/sqlStatementRanges"; +import type { DatabaseType } from "@/types/database"; // `@lezer/common` is only a transitive dependency here (see sqlSyntaxTreeWindow.ts's comment on // the same pattern), so derive the node types structurally instead of importing them. @@ -62,7 +64,7 @@ function isSqlServerTransactionBegin(state: EditorState, tokens: SyntaxNode[], i // watcher), and when Lezer's incremental parser finishes covering more of a large document in the // background -- both produce a new `Tree` without a new `Text`, so keying on `Text` alone would // keep serving ranges computed from a stale or partial parse. -const rangeCache = new WeakMap>(); +const rangeCache = new WeakMap>>(); function addMultilineFoldRange(state: EditorState, ranges: Map, openerPosition: number, endPosition: number, overwrite = false) { const openerLine = state.doc.lineAt(openerPosition); @@ -154,12 +156,32 @@ function addQueryStructureFoldRanges(state: EditorState, tree: Tree, ranges: Map } } -function computeBlockFoldRanges(state: EditorState): Map { +function addRestRequestFoldRanges(state: EditorState, ranges: Map, databaseType?: DatabaseType): boolean { + const requests = elasticsearchRestRequestRanges(state.doc.toString(), databaseType ?? "elasticsearch"); + if (requests.length === 0) return false; + + for (const request of requests) { + addMultilineFoldRange(state, ranges, request.from, request.to); + } + return true; +} + +function computeBlockFoldRanges(state: EditorState, databaseType?: DatabaseType): Map { const tree = syntaxTree(state); - const cached = rangeCache.get(tree); + const cacheByDatabaseType = rangeCache.get(tree); + const cacheKey = databaseType ?? "auto-detect"; + const cached = cacheByDatabaseType?.get(cacheKey); if (cached) return cached; const byOpenerLine = new Map(); + const isRestDocument = addRestRequestFoldRanges(state, byOpenerLine, databaseType); + if (isRestDocument) { + const nextCacheByDatabaseType = cacheByDatabaseType ?? new Map>(); + nextCacheByDatabaseType.set(cacheKey, byOpenerLine); + rangeCache.set(tree, nextCacheByDatabaseType); + return byOpenerLine; + } + const tokens: SyntaxNode[] = []; tree.iterate({ enter(node) { @@ -197,12 +219,19 @@ function computeBlockFoldRanges(state: EditorState): Map { addQueryStructureFoldRanges(state, tree, byOpenerLine); - rangeCache.set(tree, byOpenerLine); + const nextCacheByDatabaseType = cacheByDatabaseType ?? new Map>(); + nextCacheByDatabaseType.set(cacheKey, byOpenerLine); + rangeCache.set(tree, nextCacheByDatabaseType); return byOpenerLine; } -/** Adds procedural-block, query-parenthesis, and UNION-branch folds to the SQL editor. */ -export const sqlBlockFoldService = foldService.of((state, lineStart) => { - const range = computeBlockFoldRanges(state).get(state.doc.lineAt(lineStart).number); - return range ?? null; -}); +/** Creates folding for REST requests, procedural blocks, query parentheses, and UNION branches. */ +export function createSqlBlockFoldService(databaseType?: DatabaseType) { + return foldService.of((state, lineStart) => { + const range = computeBlockFoldRanges(state, databaseType).get(state.doc.lineAt(lineStart).number); + return range ?? null; + }); +} + +/** Public compatibility extension with REST request auto-detection for text editors. */ +export const sqlBlockFoldService = createSqlBlockFoldService();