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;
+}