From 46cc9be7d9e0b2c28e45df3f902be69005b315de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=B3=B0?= <67090734+dafeng-001@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:34:15 +0800 Subject: [PATCH] feat(editor): highlight unknown SQL objects --- .../src/components/editor/QueryEditor.vue | 112 ++++++------- .../editor/useQueryEditorDiagnostics.ts | 143 ++++++++++++++++- ...demirrorSqlUnknownObjectHighlights.spec.ts | 147 ++++++++++++++++++ .../codemirrorSqlUnknownObjectHighlights.ts | 144 +++++++++++++++++ 4 files changed, 490 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/lib/editor/__tests__/codemirrorSqlUnknownObjectHighlights.spec.ts create mode 100644 apps/desktop/src/lib/editor/codemirrorSqlUnknownObjectHighlights.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 5bb3111d51..fc2f82cff4 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -111,7 +111,7 @@ import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatch import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints"; import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; - +import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights"; import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; @@ -122,18 +122,12 @@ import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOcc import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection"; import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut"; import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut"; - import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut"; - import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; - import { queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; - import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; - import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect"; - import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion"; const props = defineProps(); @@ -160,6 +154,9 @@ const COMPLETION_ENTER_MAX_WAIT_MS = 125; const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000; // Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path. const SEMANTIC_SQL_COMPLETION_ENABLED = true; +// Master switch for colouring table/column names that the connected database does not have. +// There is no settings entry yet: flip this to false to turn the whole feature off. +const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = true; const emit = defineEmits<{ "update:modelValue": [value: string]; @@ -216,7 +213,8 @@ const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.ge const MAX_COMPLETION_TABLES = 200; const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2; const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20; - +const SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS = 900; +const SQL_UNKNOWN_OBJECT_DEBOUNCE_MS = 600; const liveFontSize = ref(settingsStore.editorSettings.fontSize); const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize); const isGestureZooming = ref(false); @@ -1499,52 +1497,55 @@ async function expandSelectStar(target = selectStarExpansionTarget.value) { currentView.focus(); } -const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics } = useQueryEditorDiagnostics({ - props, - view, - settingsStore, - connectionStore, - sqlDriverProfile, - sqlStatementParameterOptions, - sqlBehaviorDialect, - semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, - maxCompletionTables: MAX_COMPLETION_TABLES, - runtime: { - get setSqlDiagnosticsEffect() { - return codeMirrorRuntime.setSqlDiagnosticsEffect; +const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics, loadSqlUnknownObjectSpans } = + useQueryEditorDiagnostics({ + props, + view, + settingsStore, + connectionStore, + sqlDriverProfile, + sqlStatementParameterOptions, + sqlBehaviorDialect, + queryEditorSelectionLanguage, + semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, + maxCompletionTables: MAX_COMPLETION_TABLES, + unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED, + runtime: { + get setSqlDiagnosticsEffect() { + return codeMirrorRuntime.setSqlDiagnosticsEffect; + }, + get diagnosticComp() { + return codeMirrorRuntime.diagnosticComp; + }, + get buildSqlDiagnosticExtension() { + return codeMirrorRuntime.buildSqlDiagnosticExtension; + }, + get codeMirrorCompletionStatus() { + return codeMirrorRuntime.codeMirrorCompletionStatus; + }, + get executableStatementRangeCache() { + return executableStatementRangeCache; + }, + set executableStatementRangeCache(value) { + executableStatementRangeCache = value; + }, + get editorIsActive() { + return editorIsActive; + }, }, - get diagnosticComp() { - return codeMirrorRuntime.diagnosticComp; + metadata: { + get cachedTables() { + return completionMetadata.cachedTables; + }, + cachedColumnsByTable, + loadedColumnsByTable, + usesOracleSessionCompletionColumns, + findExactSemanticDiagnosticTable, + completionCacheKey, + ensureColumnsForTable, + isMissingTableMetadataError, }, - get buildSqlDiagnosticExtension() { - return codeMirrorRuntime.buildSqlDiagnosticExtension; - }, - get codeMirrorCompletionStatus() { - return codeMirrorRuntime.codeMirrorCompletionStatus; - }, - get executableStatementRangeCache() { - return executableStatementRangeCache; - }, - set executableStatementRangeCache(value) { - executableStatementRangeCache = value; - }, - get editorIsActive() { - return editorIsActive; - }, - }, - metadata: { - get cachedTables() { - return completionMetadata.cachedTables; - }, - cachedColumnsByTable, - loadedColumnsByTable, - usesOracleSessionCompletionColumns, - findExactSemanticDiagnosticTable, - completionCacheKey, - ensureColumnsForTable, - isMissingTableMetadataError, - }, -}); + }); async function formatCurrentSql() { if (props.readOnly) return; @@ -1872,6 +1873,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])), initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()), initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()), + createSqlUnknownObjectHighlights({ + enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED, + load: loadSqlUnknownObjectSpans, + initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS, + debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS, + }), tooltips({ parent: tooltipParent }), initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()), sqlCompletionTheme(EditorView), @@ -2495,6 +2502,7 @@ function resumeQueryEditorBackgroundWork() { editorIsActive = true; registerTableReferenceDropListener(); scheduleSemanticDiagnostics(); + view.value?.dispatch({ effects: [refreshSqlUnknownObjectHighlights.of(null)] }); // Warm the database driver/pool while the user is still reading or typing, so // the first Run does not pay pool creation or external-driver startup. warmActiveTabConnection(); diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index 88907a7c6c..c90ba06696 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -17,6 +17,8 @@ import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics" import { buildSqlServerRoutineSyntaxDiagnostics } from "@/lib/sql/sqlServerRoutineSyntaxDiagnostics"; import { needsDiagnosticCaretReanchor } from "@/lib/editor/queryEditorDiagnosticCaretAnchor"; import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; +import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint"; +import type { SqlUnknownObjectSpan } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights"; import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, @@ -29,11 +31,11 @@ import { tableReferenceKey, type SqlSemanticDiagnostic, } from "@/lib/sql/semantic/diagnostics"; -import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect"; +import { resolveSqlDialectId, sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect"; import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics"; import { buildMongoSyntaxDiagnostics } from "@/lib/mongo/mongoSyntaxDiagnostics"; import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; -import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; +import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; interface QueryEditorDiagnosticsRuntime { setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null; @@ -63,8 +65,10 @@ interface QueryEditorDiagnosticsOptions { sqlDriverProfile: ComputedRef; sqlStatementParameterOptions: () => SqlParameterOptions; sqlBehaviorDialect: () => QueryEditorProps["dialect"]; + queryEditorSelectionLanguage: () => "sql" | "text"; semanticCompletionEnabled: boolean; maxCompletionTables: number; + unknownObjectHighlightEnabled: boolean; runtime: QueryEditorDiagnosticsRuntime; metadata: QueryEditorDiagnosticMetadata; } @@ -73,6 +77,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options; const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled; const MAX_COMPLETION_TABLES = options.maxCompletionTables; + const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = options.unknownObjectHighlightEnabled; const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4; let semanticDiagnostics: SqlSemanticDiagnostic[] = []; let semanticDiagnosticTimer: ReturnType | null = null; @@ -287,7 +292,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions return { tables: enriched, missingTables }; } - async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise> { + async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope, limit = MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES): Promise> { const missingTables = new Set(); const seen = new Set(); const targets: SqlTableReference[] = []; @@ -303,7 +308,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions if (seen.has(normalizedKey)) continue; seen.add(normalizedKey); targets.push(table); - if (targets.length >= MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES) break; + if (targets.length >= limit) break; } await Promise.all( targets.map(async (table) => { @@ -493,6 +498,135 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions onBeforeUnmount(clearScheduledSemanticDiagnostics); + // ==================== Unknown table/column highlighting ==================== + const NON_SQL_UNKNOWN_OBJECT_DATABASE_TYPES: ReadonlySet = new Set(["qdrant", "milvus", "weaviate", "chromadb", "solr"]); + const MAX_SQL_UNKNOWN_OBJECT_SQL_LENGTH = 200_000; + const MAX_SQL_UNKNOWN_OBJECT_STATEMENTS = 80; + const SQL_UNKNOWN_OBJECT_COLUMN_TABLE_LIMIT = 24; + const MAX_SQL_UNKNOWN_OBJECT_MEMO_ENTRIES = 200; + const sqlUnknownObjectSpanMemo = new Map(); + + function sqlUnknownObjectMemoKey(range: SqlTextRange, metadataEpoch: string): string { + return `${sqlTextFingerprint(range.sql)}|${range.from}:${range.to}|${metadataEpoch}`; + } + + function rememberSqlUnknownObjectSpans(key: string, spans: SqlUnknownObjectSpan[]) { + sqlUnknownObjectSpanMemo.set(key, spans); + while (sqlUnknownObjectSpanMemo.size > MAX_SQL_UNKNOWN_OBJECT_MEMO_ENTRIES) { + const oldest = sqlUnknownObjectSpanMemo.keys().next(); + if (oldest.done) break; + sqlUnknownObjectSpanMemo.delete(oldest.value); + } + } + + async function loadSqlUnknownObjectSpans(currentView: EditorViewType): Promise { + if (!SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED || !runtime.editorIsActive) return []; + if (options.queryEditorSelectionLanguage() !== "sql") return []; + if (shouldSkipSqlSemanticDiagnostics()) return []; + if (props.databaseType && NON_SQL_UNKNOWN_OBJECT_DATABASE_TYPES.has(props.databaseType)) return []; + if (!props.connectionId || props.database == null) return []; + const doc = currentView.state.doc; + if (doc.length > MAX_SQL_UNKNOWN_OBJECT_SQL_LENGTH) return []; + const sql = doc.toString(); + if (!sql.trim()) return []; + + if (props.databaseType !== "sqlserver") { + runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, doc, props.databaseType, sqlStatementParameterOptions()); + } + const ranges = sqlSemanticDiagnosticRangesForViewport(sql, [{ from: 0, to: sql.length }], props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions()).slice(0, MAX_SQL_UNKNOWN_OBJECT_STATEMENTS); + if (ranges.length === 0) return []; + + const metadataEpoch = `${resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() })}|${props.connectionId}|${props.database}|${props.schema ?? ""}|${props.databaseType}|${semanticDiagnosticRunId}|${metadata.cachedColumnsByTable.size}:${metadata.loadedColumnsByTable.size}`; + + const spans: SqlUnknownObjectSpan[] = []; + let analyzedStatements = 0; + let touchedTables = 0; + let columnMetadataLoads = 0; + + for (const range of ranges) { + const memoKey = sqlUnknownObjectMemoKey(range, metadataEpoch); + const cachedSpans = sqlUnknownObjectSpanMemo.get(memoKey); + if (cachedSpans) { + spans.push(...cachedSpans); + continue; + } + if (currentView.state.doc !== doc) return []; + + const statementSpans: SqlUnknownObjectSpan[] = []; + try { + const analysis = await api.analyzeSqlReferences( + range.sql, + sqlReferenceAnalysisDialectFor({ + databaseType: props.databaseType, + identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId), + fallbackDialect: props.formatDialect ?? props.dialect ?? "generic", + }), + ); + if (currentView.state.doc !== doc) return []; + + const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length)); + const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED + ? buildSqlSemanticModel(range.sql, semanticCursor, { + databaseType: props.databaseType, + dialect: sqlBehaviorDialect(), + }) + : null; + const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis; + const metadataScope = semanticDiagnosticMetadataScope(sql, range); + const scopedAnalysis = { + ...semanticAnalysis, + tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope), + }; + const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope); + const loadedColumnsBefore = metadata.loadedColumnsByTable.size; + const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope, SQL_UNKNOWN_OBJECT_COLUMN_TABLE_LIMIT); + for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey); + if (currentView.state.doc !== doc) return []; + + touchedTables += tables.length; + columnMetadataLoads += Math.max(0, metadata.loadedColumnsByTable.size - loadedColumnsBefore); + + const diagnostics = offsetSqlSemanticDiagnostics( + buildSqlSemanticDiagnostics( + { ...scopedAnalysis, tables }, + { + tables: metadata.cachedTables, + columnsByTable: metadata.cachedColumnsByTable, + missingTables, + loadedColumnTables: metadata.loadedColumnsByTable, + sql: range.sql, + databaseType: props.databaseType, + }, + ), + range, + sql, + ); + for (const diagnostic of diagnostics) { + const offsetRange = sqlTextSpanToRange(sql, diagnostic.span); + if (offsetRange) statementSpans.push(offsetRange); + } + } catch { + // A failed parse must not colour anything. + } + + analyzedStatements += 1; + rememberSqlUnknownObjectSpans(memoKey, statementSpans); + spans.push(...statementSpans); + } + + const dedupedSpans: SqlUnknownObjectSpan[] = []; + const seenSpans = new Set(); + for (const span of spans) { + const key = `${span.from}:${span.to}`; + if (seenSpans.has(key)) continue; + seenSpans.add(key); + dedupedSpans.push(span); + } + dedupedSpans.sort((left, right) => left.from - right.from || left.to - right.to); + + return dedupedSpans; + } + return { sqlErrorDecorationRange, sqlSemanticDecorationRanges, @@ -502,6 +636,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics, + loadSqlUnknownObjectSpans, get diagnostics() { return semanticDiagnostics; }, diff --git a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlUnknownObjectHighlights.spec.ts b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlUnknownObjectHighlights.spec.ts new file mode 100644 index 0000000000..c0d2a02122 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlUnknownObjectHighlights.spec.ts @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights, setSqlUnknownObjectSpans, type SqlUnknownObjectSpan } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights"; + +const DOC = "SELECT USER_ID FROM T"; + +let view: EditorView | null = null; + +function mount(doc: string, options: { enabled?: boolean; load?: (view: EditorView) => Promise } = {}) { + const parent = document.createElement("div"); + document.body.appendChild(parent); + view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [ + createSqlUnknownObjectHighlights({ + enabled: options.enabled ?? true, + load: options.load ?? (async () => []), + initialDelayMs: 0, + debounceMs: 0, + }), + ], + }), + }); + return view; +} + +/** Let the zero-delay deferred task fire and its promise chain settle. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function highlightedText(current: EditorView): string[] { + return Array.from(current.dom.querySelectorAll(".cm-sql-unknown-object")).map((element) => element.textContent ?? ""); +} + +afterEach(() => { + view?.destroy(); + view = null; + document.body.innerHTML = ""; +}); + +describe("createSqlUnknownObjectHighlights", () => { + it("returns no extension when disabled", () => { + expect(createSqlUnknownObjectHighlights({ enabled: false, load: async () => [], initialDelayMs: 0, debounceMs: 0 })).toEqual([]); + }); + + it("colours the ranges supplied through the effect", () => { + const current = mount(DOC); + current.dispatch({ effects: setSqlUnknownObjectSpans.of([{ from: 7, to: 14 }]) }); + + expect(highlightedText(current)).toEqual(["USER_ID"]); + + current.dispatch({ effects: setSqlUnknownObjectSpans.of([]) }); + expect(highlightedText(current)).toEqual([]); + }); + + it("clamps, dedupes and orders the supplied ranges", () => { + const current = mount(DOC); + current.dispatch({ + effects: setSqlUnknownObjectSpans.of([ + { from: 15, to: 500 }, + { from: 7, to: 14 }, + { from: 7, to: 14 }, + { from: 3, to: 3 }, + ]), + }); + + // Ordered by start, duplicate collapsed, out-of-range clipped to the document end, empty dropped. + expect(highlightedText(current)).toEqual(["USER_ID", "FROM T"]); + }); + + it("shifts existing highlights through document changes without clearing them", () => { + const current = mount(DOC); + current.dispatch({ effects: setSqlUnknownObjectSpans.of([{ from: 7, to: 14 }]) }); + + current.dispatch({ changes: { from: 0, insert: "XX" } }); + + expect(highlightedText(current)).toEqual(["USER_ID"]); + }); + + it("runs load on mount and applies the resolved spans", async () => { + const load = vi.fn(async () => [{ from: 7, to: 14 }]); + const current = mount(DOC, { load }); + + expect(highlightedText(current)).toEqual([]); + await settle(); + + expect(load).toHaveBeenCalledTimes(1); + expect(highlightedText(current)).toEqual(["USER_ID"]); + }); + + it("re-runs load when a refresh effect is dispatched", async () => { + const load = vi.fn(async () => [{ from: 7, to: 14 }]); + const current = mount(DOC, { load }); + await settle(); + expect(load).toHaveBeenCalledTimes(1); + + current.dispatch({ effects: refreshSqlUnknownObjectHighlights.of(null) }); + await settle(); + + expect(load).toHaveBeenCalledTimes(2); + expect(highlightedText(current)).toEqual(["USER_ID"]); + }); + + it("discards a result computed against an older document", async () => { + let resolveLoad: ((spans: SqlUnknownObjectSpan[]) => void) | null = null; + const load = () => + new Promise((resolve) => { + resolveLoad = resolve; + }); + const current = mount(DOC, { load }); + await settle(); + + current.dispatch({ changes: { from: 0, insert: "XX" } }); + resolveLoad?.([{ from: 7, to: 14 }]); + await settle(); + + expect(highlightedText(current)).toEqual([]); + }); + + it("ignores a load failure", async () => { + const load = async () => { + throw new Error("metadata unavailable"); + }; + const current = mount(DOC, { load }); + await settle(); + + expect(highlightedText(current)).toEqual([]); + }); + + it("emits an !important colour rule that outranks the table-name colour", () => { + mount(DOC); + const css = Array.from(document.querySelectorAll("style")) + .map((style) => style.textContent ?? "") + .join("\n"); + + // The repeated class is what beats .cm-sql-table-name (same specificity as a + // single class once the generated theme id prefix is accounted for). + expect(css).toContain(".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object"); + expect(css).toContain("var(--dbx-sql-unknown-object-color, #e5484d) !important"); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorSqlUnknownObjectHighlights.ts b/apps/desktop/src/lib/editor/codemirrorSqlUnknownObjectHighlights.ts new file mode 100644 index 0000000000..a1b7043f54 --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorSqlUnknownObjectHighlights.ts @@ -0,0 +1,144 @@ +import { StateEffect, StateField, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask"; + +/** Absolute document range that must be rendered with the "unknown object" colour. */ +export interface SqlUnknownObjectSpan { + from: number; + to: number; +} + +export const setSqlUnknownObjectSpans = StateEffect.define(); +/** + * Ask a mounted editor to re-run `load` immediately (used on tab activation, + * where KeepAlive does not necessarily produce a focus or viewport change). + */ +export const refreshSqlUnknownObjectHighlights = StateEffect.define(); + +export interface SqlUnknownObjectHighlightOptions { + enabled: boolean; + /** Resolves the ranges to colour. Called off the typing path, never per keystroke. */ + load: (view: EditorView) => Promise; + initialDelayMs: number; + debounceMs: number; +} + +const unknownObjectMark = Decoration.mark({ + class: "cm-sql-unknown-object", + attributes: { + "data-sql-token": "unknown", + }, +}); + +function buildDecorationSet(spans: readonly SqlUnknownObjectSpan[], docLength: number): DecorationSet { + const normalized: SqlUnknownObjectSpan[] = []; + const seen = new Set(); + for (const span of spans) { + const from = Math.max(0, Math.min(span.from, docLength)); + const to = Math.max(0, Math.min(span.to, docLength)); + if (to <= from) continue; + const key = `${from}:${to}`; + if (seen.has(key)) continue; + seen.add(key); + normalized.push({ from, to }); + } + // DecorationSet requires ranges in ascending order. + normalized.sort((left, right) => left.from - right.from || left.to - right.to); + return Decoration.set( + normalized.map((span) => unknownObjectMark.range(span.from, span.to)), + true, + ); +} + +export function createSqlUnknownObjectHighlights(options: SqlUnknownObjectHighlightOptions): Extension { + if (!options.enabled) return []; + + // Keeps the decorations as a DecorationSet so that typing shifts them through + // the ChangeSet instead of clearing them until the next deferred scan lands. + const spansField = StateField.define({ + create: () => Decoration.none, + update(decorations, transaction) { + for (const effect of transaction.effects) { + if (effect.is(setSqlUnknownObjectSpans)) return buildDecorationSet(effect.value, transaction.state.doc.length); + } + if (transaction.docChanged) return decorations.map(transaction.changes); + return decorations; + }, + provide: (field) => EditorView.decorations.from(field), + }); + + const plugin = ViewPlugin.fromClass( + class { + private running = false; + private view: EditorView; + private readonly runTask: () => void; + private readonly initialTask: ReturnType; + private readonly changeTask: ReturnType; + + constructor(view: EditorView) { + this.view = view; + this.runTask = () => { + void this.run(); + }; + this.initialTask = createDeferredEditorTask(this.runTask, options.initialDelayMs); + this.changeTask = createDeferredEditorTask(this.runTask, options.debounceMs); + this.initialTask.schedule(); + } + + update(update: ViewUpdate) { + this.view = update.view; + if (update.docChanged) { + this.initialTask.cancel(); + this.changeTask.schedule(); + return; + } + if (update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshSqlUnknownObjectHighlights)))) { + this.initialTask.cancel(); + this.changeTask.cancel(); + this.runTask(); + } + } + + destroy() { + this.initialTask.cancel(); + this.changeTask.cancel(); + } + + private async run() { + if (this.running) return; + const view = this.view; + if (!view.dom.isConnected) return; + const doc = view.state.doc; + this.running = true; + let spans: SqlUnknownObjectSpan[] = []; + try { + spans = await options.load(view); + } catch { + spans = []; + } finally { + this.running = false; + } + // A result computed against an older document (or a detached editor) is + // stale: the next debounced run owns the current text. + if (!view.dom.isConnected || view.state.doc !== doc) return; + view.dispatch({ effects: setSqlUnknownObjectSpans.of(spans) }); + } + }, + ); + + return [ + spansField, + plugin, + EditorView.baseTheme({ + // The generated theme class prefixes this selector, and the table-name + // colour is an `!important` rule at the same specificity, so the class is + // repeated to win the cascade regardless of style-module injection order. + ".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object": { + color: "var(--dbx-sql-unknown-object-color, #e5484d) !important", + }, + ".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object *": { + color: "var(--dbx-sql-unknown-object-color, #e5484d) !important", + }, + }), + ]; +}