From ccb91784b350b64cf43b168f595ad23bcd453e6e Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 30 Jun 2026 18:05:37 +0800 Subject: [PATCH] perf(editor): cache executable statement ranges for large SQL scrolling Co-authored-by: staff --- .../src/components/editor/QueryEditor.vue | 26 +++++------ .../executableStatementRangeCache.spec.ts | 43 +++++++++++++++++++ .../src/lib/executableStatementRangeCache.ts | 25 +++++++++++ 3 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/executableStatementRangeCache.spec.ts create mode 100644 apps/desktop/src/lib/executableStatementRangeCache.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 85081ae440..9437a9860d 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -10,7 +10,8 @@ import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { copyToClipboard } from "@/lib/clipboard"; import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sqlExecutionTarget"; -import { buildExecutionCandidates, executableStatementRanges, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sqlStatementRanges"; +import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sqlStatementRanges"; +import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/executableStatementRangeCache"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter"; import { formatMongoShellText } from "@/lib/mongoFormatter"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -213,6 +214,7 @@ let editorIsActive = true; let tableReferenceDropListenerRegistered = false; let imeCompositionActive = false; let pendingImeModelEmit = false; +let executableStatementRangeCache: ExecutableStatementRangeCache | null = null; const tableNavigationHoverClass = "query-editor--table-navigation-hover"; function editorThemeAppearance() { @@ -512,7 +514,8 @@ function openTableDdlFromContextMenu() { } function executableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) { - return executableStatementRanges(currentView.state.doc.toString(), props.databaseType).find((range) => range.from === lineFrom) ?? null; + executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType); + return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom); } function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean { @@ -2050,25 +2053,18 @@ onMounted(async () => { const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors()); class RunStatementGutterMarker extends GutterMarker { - constructor(private readonly isExecutable: boolean) { - super(); - } - toDOM() { - const marker = document.createElement(this.isExecutable ? "button" : "span"); - marker.className = this.isExecutable ? "cm-run-statement-marker cm-run-statement-marker--active" : "cm-run-statement-marker"; - if (this.isExecutable) { - marker.setAttribute("type", "button"); - marker.setAttribute("aria-label", "Execute statement"); - } + const marker = document.createElement("button"); + marker.className = "cm-run-statement-marker cm-run-statement-marker--active"; + marker.setAttribute("type", "button"); + marker.setAttribute("aria-label", "Execute statement"); marker.innerHTML = ''; return marker; } } - const executableStatementMarker = new RunStatementGutterMarker(true); - const inactiveStatementMarker = new RunStatementGutterMarker(false); + const executableStatementMarker = new RunStatementGutterMarker(); const activeLineHighlighter = ViewPlugin.fromClass( class { @@ -2112,7 +2108,7 @@ onMounted(async () => { gutter({ class: "cm-run-statement-gutter", lineMarker(currentView, line) { - return executableStatementRangeStartingAt(currentView, line.from) ? executableStatementMarker : inactiveStatementMarker; + return executableStatementRangeStartingAt(currentView, line.from) ? executableStatementMarker : null; }, domEventHandlers: { mousedown: executeSqlStatementFromGutter, diff --git a/apps/desktop/src/lib/__tests__/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/executableStatementRangeCache.spec.ts new file mode 100644 index 0000000000..39a5ea1691 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/executableStatementRangeCache.spec.ts @@ -0,0 +1,43 @@ +import { Text } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/executableStatementRangeCache"; + +describe("executableStatementRangeCacheForDoc", () => { + it("reuses parsed executable statement ranges for the same document and database type", () => { + const doc = Text.of(["SELECT 1;", "SELECT 2;"]); + const parse = vi.fn(() => [ + { from: 0, to: 8, sql: "SELECT 1" }, + { from: 10, to: 18, sql: "SELECT 2" }, + ]); + + const first = executableStatementRangeCacheForDoc(null, doc, "mysql", parse); + const second = executableStatementRangeCacheForDoc(first, doc, "mysql", parse); + + expect(second).toBe(first); + expect(parse).toHaveBeenCalledTimes(1); + expect(executableStatementRangeStartingAt(second, 10)?.sql).toBe("SELECT 2"); + }); + + it("rebuilds the cache when the document instance changes", () => { + const firstDoc = Text.of(["SELECT 1;"]); + const secondDoc = Text.of(["SELECT 1;"]); + const parse = vi.fn(() => [{ from: 0, to: 8, sql: "SELECT 1" }]); + + const first = executableStatementRangeCacheForDoc(null, firstDoc, "mysql", parse); + const second = executableStatementRangeCacheForDoc(first, secondDoc, "mysql", parse); + + expect(second).not.toBe(first); + expect(parse).toHaveBeenCalledTimes(2); + }); + + it("rebuilds the cache when the database type changes", () => { + const doc = Text.of(["SELECT 1;"]); + const parse = vi.fn(() => [{ from: 0, to: 8, sql: "SELECT 1" }]); + + const mysql = executableStatementRangeCacheForDoc(null, doc, "mysql", parse); + const postgres = executableStatementRangeCacheForDoc(mysql, doc, "postgres", parse); + + expect(postgres).not.toBe(mysql); + expect(parse).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/lib/executableStatementRangeCache.ts b/apps/desktop/src/lib/executableStatementRangeCache.ts new file mode 100644 index 0000000000..46b7f00b0b --- /dev/null +++ b/apps/desktop/src/lib/executableStatementRangeCache.ts @@ -0,0 +1,25 @@ +import type { Text } from "@codemirror/state"; +import type { DatabaseType } from "@/types/database"; +import { executableStatementRanges, type SqlTextRange } from "@/lib/sqlStatementRanges"; + +export interface ExecutableStatementRangeCache { + doc: Text; + databaseType?: DatabaseType; + byStart: Map; +} + +export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType) => SqlTextRange[]; + +export function executableStatementRangeCacheForDoc(cache: ExecutableStatementRangeCache | null, doc: Text, databaseType?: DatabaseType, parse: ExecutableStatementRangeParser = executableStatementRanges): ExecutableStatementRangeCache { + if (cache?.doc === doc && cache.databaseType === databaseType) return cache; + + const byStart = new Map(); + for (const range of parse(doc.toString(), databaseType)) { + byStart.set(range.from, range); + } + return { doc, databaseType, byStart }; +} + +export function executableStatementRangeStartingAt(cache: ExecutableStatementRangeCache, lineFrom: number): SqlTextRange | null { + return cache.byStart.get(lineFrom) ?? null; +}