From fcb6ea92db3794b31880818ebdafc58397296ba8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 22 Aug 2026 05:06:29 +0000 Subject: [PATCH] fix(editor): cache semantic highlight windows Closes #6936 --- .../src/components/editor/QueryEditor.vue | 82 +++++++++++++++---- ...EditorSemanticHighlightPerformance.spec.ts | 25 ++++++ 2 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 648d11d18a..2d95ab8dc0 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -4780,34 +4780,86 @@ onMounted(async () => { // The SQL grammar does not tokenize `//`, so those comments are highlighted by hand. queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : [], ]; + const MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32; buildSqlSemanticHighlightExtension = () => [ ViewPlugin.fromClass( class { decorations: import("@codemirror/view").DecorationSet; + private cachedDoc: import("@codemirror/state").Text | null = null; + private cachedSql = ""; + private cachedDialectId = ""; + private cachedDatabaseType: DatabaseType | undefined; + private cachedWindows: Array<{ + from: number; + to: number; + spans: Array<{ start: number; end: number }>; + }> = []; + constructor(currentView: import("@codemirror/view").EditorView) { this.decorations = this.buildDecorations(currentView); } + update(update: import("@codemirror/view").ViewUpdate) { if (update.docChanged || update.viewportChanged) this.decorations = this.buildDecorations(update.view); } + buildDecorations(currentView: import("@codemirror/view").EditorView) { - const sql = currentView.state.doc.toString(); const dialectId = resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }); - const windows: Array<{ from: number; to: number }> = []; - for (const visibleRange of currentView.visibleRanges) { - const next = expandToSqlStatementWindow(sql, visibleRange.from, visibleRange.to, dialectId); - const previous = windows[windows.length - 1]; - if (previous && next.from <= previous.to) previous.to = Math.max(previous.to, next.to); - else windows.push(next); + const doc = currentView.state.doc; + if (this.cachedDoc !== doc || this.cachedDialectId !== dialectId || this.cachedDatabaseType !== props.databaseType) { + this.cachedDoc = doc; + this.cachedSql = doc.toString(); + this.cachedDialectId = dialectId; + this.cachedDatabaseType = props.databaseType; + this.cachedWindows = []; } - const tree = ensureSyntaxTree(currentView.state, windows[windows.length - 1]?.to ?? 0, 25); - if (!tree) return Decoration.set([]); - const ranges = windows.flatMap((window) => - sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, { - databaseType: props.databaseType, - dialect: sqlBehaviorDialect(), - }), - ); + + const sql = this.cachedSql; + const windows: Array<{ + from: number; + to: number; + spans: Array<{ start: number; end: number }>; + }> = []; + const pendingWindows: Array<{ from: number; to: number }> = []; + for (const visibleRange of currentView.visibleRanges) { + const cached = this.cachedWindows.find((candidate) => candidate.from <= visibleRange.from && candidate.to >= visibleRange.to); + if (cached) { + if (!windows.includes(cached)) windows.push(cached); + continue; + } + + const next = expandToSqlStatementWindow(sql, visibleRange.from, visibleRange.to, dialectId); + const cachedWindow = this.cachedWindows.find((candidate) => candidate.from <= next.from && candidate.to >= next.to); + if (cachedWindow) { + if (!windows.includes(cachedWindow)) windows.push(cachedWindow); + continue; + } + + const previous = pendingWindows[pendingWindows.length - 1]; + if (previous && next.from <= previous.to) previous.to = Math.max(previous.to, next.to); + else pendingWindows.push({ ...next }); + } + + if (pendingWindows.length > 0) { + const tree = ensureSyntaxTree(currentView.state, Math.max(...pendingWindows.map((window) => window.to)), 25); + if (!tree) return Decoration.set([]); + for (const window of pendingWindows) { + const entry = { + ...window, + spans: sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, { + databaseType: props.databaseType, + dialect: sqlBehaviorDialect(), + }), + }; + this.cachedWindows.push(entry); + windows.push(entry); + } + if (this.cachedWindows.length > MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS) { + this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS); + } + } + + const ranges = windows.flatMap((window) => window.spans); return Decoration.set( ranges.map((range) => Decoration.mark({ diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts new file mode 100644 index 0000000000..66ed23824d --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorSemanticHighlightPerformance.spec.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +describe("QueryEditor semantic highlighting while scrolling", () => { + it("recognizes the large single-statement shape from the reported regression", () => { + const sql = `CREATE TABLE [dbo].[code] ([id] int, [label] nvarchar(32));\nINSERT INTO [dbo].[code] ([id], [label]) VALUES\n${Array.from({ length: 240 }, (_, index) => `(${index}, N'row-${String(index).padStart(4, "0")}'),`).join("\n")}`; + const insertStart = sql.indexOf("INSERT"); + const window = expandToSqlStatementWindow(sql, insertStart + 80, insertStart + 120, "sqlserver"); + + expect(window.to - window.from).toBeGreaterThan(2_000); + expect(window.from).toBe(insertStart); + }); + + it("reuses semantic statement windows across viewport-only updates", () => { + expect(queryEditorSource).toContain('private cachedDoc: import("@codemirror/state").Text | null = null;'); + expect(queryEditorSource).toContain("private cachedWindows: Array<{"); + expect(queryEditorSource).toContain("const cached = this.cachedWindows.find"); + expect(queryEditorSource).toContain("const pendingWindows: Array<{ from: number; to: number }>"); + expect(queryEditorSource).toContain("MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32"); + expect(queryEditorSource).toContain("this.cachedWindows.splice(0, this.cachedWindows.length - MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS)"); + }); +});