From a4129c02fa3a0b00f8a04ca15ae8eae6f7309d9c Mon Sep 17 00:00:00 2001 From: azens <892553927@qq.com> Date: Sun, 9 Aug 2026 19:51:12 +0800 Subject: [PATCH] fix(editor): rebuild current SQL statement frame with pixel-accurate layer --- .../editor/EditorSettingsDialog.vue | 93 +++------- .../src/components/editor/QueryEditor.vue | 154 +++++++--------- .../editor/currentStatementFrameLayer.spec.ts | 172 ++++++++++++++++++ .../editor/queryEditorScrollPastEnd.spec.ts | 3 +- .../sql/currentStatementFrame.spec.ts | 55 +----- .../codemirrorCurrentStatementFrameLayer.ts | 131 +++++++++++++ .../src/lib/sql/currentStatementFrame.ts | 58 ------ 7 files changed, 397 insertions(+), 269 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts create mode 100644 apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index fcd3ed133..cda24bc37 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -95,7 +95,8 @@ import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keybo import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; -import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; import { validateConfigName, generateId, type AiConfigItem, type ConfigNameValidationResult } from "@/lib/ai/aiConfigList"; import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; @@ -3211,79 +3212,28 @@ function handlePreviewRunGutterMouseDown(currentView: EditorViewType, line: { fr return true; } -function buildPreviewCurrentStatementFrameExtension(viewModule: Pick, enabled: boolean) { +function buildPreviewCurrentStatementFrameExtension(viewModule: Pick, enabled: boolean) { if (!enabled) return []; - const { Decoration, EditorView, ViewPlugin } = viewModule; + const { EditorView } = viewModule; const frameTheme = EditorView.baseTheme({ - ".cm-db-current-statement-line": { - position: "relative", - }, - ".cm-db-current-statement-line::after": { - content: '""', - position: "absolute", - top: "0", - bottom: "0", - left: "0", - boxSizing: "border-box", - width: "var(--dbx-current-statement-frame-width, 100%)", - borderRight: "1px solid rgb(34 197 94 / 0.75)", - borderLeft: "1px solid rgb(34 197 94 / 0.75)", + ".cm-db-currentStatementFrameLayer": { pointerEvents: "none", }, - ".cm-db-current-statement-line--first::after": { - borderTop: "1px solid rgb(34 197 94 / 0.75)", - }, - ".cm-db-current-statement-line--last::after": { - borderBottom: "1px solid rgb(34 197 94 / 0.75)", + ".cm-db-currentStatementFrame": { + boxSizing: "border-box", + border: "1px solid rgb(34 197 94 / 0.75)", + borderRadius: "2px", + pointerEvents: "none", }, }); - const framePlugin = ViewPlugin.fromClass( - class { - decorations: import("@codemirror/view").DecorationSet; - constructor(view: import("@codemirror/view").EditorView) { - this.decorations = this.getDeco(view); - } - update(update: import("@codemirror/view").ViewUpdate) { - this.decorations = this.getDeco(update.view); - } - getDeco(view: import("@codemirror/view").EditorView) { - if (view.state.selection.ranges.some((range) => !range.empty)) return Decoration.none; - const range = currentExecutableStatementRange(view.state.doc.toString(), view.state.selection.main.head, "mysql"); - if (!range) return Decoration.none; + const frameLayer = currentStatementFrameLayer({ layer: viewModule.layer, RectangleMarker: viewModule.RectangleMarker }, (view) => { + if (view.state.selection.ranges.some((range) => !range.empty)) return null; + const range = currentExecutableStatementRange(view.state.doc.toString(), view.state.selection.main.head, "mysql"); + if (!range) return null; + return { from: range.from, to: previewCurrentStatementFrameTo(view, range) }; + }); - const startLine = view.state.doc.lineAt(range.from); - const frameTo = previewCurrentStatementFrameTo(view, range); - const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1)); - let maxWidth = 1; - for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) { - const line = view.state.doc.line(lineNumber); - const lineRangeTo = Math.min(line.to, frameTo); - maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo)); - } - - const deco: any[] = []; - const frameWidth = `calc(${maxWidth}ch + 2ch)`; - for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) { - const line = view.state.doc.line(lineNumber); - const classes = ["cm-db-current-statement-line"]; - if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first"); - if (lineNumber === endLine.number) classes.push("cm-db-current-statement-line--last"); - deco.push( - Decoration.line({ - class: classes.join(" "), - attributes: { - style: `--dbx-current-statement-frame-width: ${frameWidth};`, - }, - }).range(line.from), - ); - } - return Decoration.set(deco); - } - }, - { decorations: (v) => v.decorations }, - ); - - return [framePlugin, frameTheme]; + return [frameLayer, frameTheme]; } function previewCurrentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number { @@ -3348,12 +3298,19 @@ watch(previewRef, async (el) => { previewInitialized = true; if (previewView.value) return; - const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]); + const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker, layer, RectangleMarker }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([ + import("@codemirror/view"), + import("@codemirror/state"), + import("@codemirror/lang-sql"), + import("codemirror"), + ]); editorViewModule = { Decoration, EditorView, ViewPlugin, + layer, + RectangleMarker, } as typeof import("@codemirror/view"); fontThemeComp = new Compartment(); themeComp = new Compartment(); diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 5e2e4785f..8d4513dd0 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -15,8 +15,8 @@ import { completionMatchRanges } from "@/lib/common/completionMatch"; import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget"; import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; -import { currentStatementFrameRangeTo, shouldRebuildCurrentStatementFrame, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; -import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints"; +import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame"; +import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints"; import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns"; import { formatSqlForEditing, compressSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { detectAndFormatStructured } from "@/lib/sql/autoFormat"; @@ -101,6 +101,7 @@ import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentat import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; +import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; @@ -3927,7 +3928,29 @@ onMounted(async () => { })(); const [ - { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin }, + { + EditorView, + keymap, + rectangularSelection, + hoverTooltip, + showTooltip, + closeHoverTooltips, + Decoration, + tooltips, + gutter, + GutterMarker, + lineNumberMarkers, + lineNumbers, + highlightActiveLineGutter, + highlightSpecialChars, + drawSelection, + dropCursor, + crosshairCursor, + scrollPastEnd, + ViewPlugin, + layer, + RectangleMarker, + }, { EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField }, langSql, { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion }, @@ -4253,74 +4276,43 @@ onMounted(async () => { await ensureCodeMirrorVim(); } - const currentStatementFrameHighlighter = ViewPlugin.fromClass( - class { - decorations: import("@codemirror/view").DecorationSet; - private configuration: string; - constructor(view: import("@codemirror/view").EditorView) { - this.configuration = this.currentConfiguration(); - this.decorations = this.getDeco(view); - } - update(update: import("@codemirror/view").ViewUpdate) { - const configuration = this.currentConfiguration(); - if (!shouldRebuildCurrentStatementFrame({ docChanged: update.docChanged, selectionSet: update.selectionSet, configurationChanged: configuration !== this.configuration })) return; - this.configuration = configuration; - this.decorations = this.getDeco(update.view); - } - currentConfiguration() { - return `${settingsStore.editorSettings.showCurrentStatementFrame}:${settingsStore.editorSettings.showInsertValueHints}:${props.databaseType ?? ""}`; - } - getDeco(view: import("@codemirror/view").EditorView) { - if (!settingsStore.editorSettings.showCurrentStatementFrame) return Decoration.none; - if (view.state.selection.ranges.some((range) => !range.empty)) return Decoration.none; - const range = currentExecutableStatementRange(view); - if (!range) return Decoration.none; - - const startLine = view.state.doc.lineAt(range.from); - const frameTo = currentStatementFrameTo(view, range); - const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1)); - let insertValueHints: Array<{ from: number; column: string }> = []; - try { - if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics") { - const statementSql = view.state.doc.sliceString(range.from, range.to); - if (/\binsert\b/i.test(statementSql)) { - insertValueHints = parseInsertValueHints(statementSql, { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({ - ...hint, - from: hint.from + range.from, - })); + const currentStatementFrameExtension = currentStatementFrameLayer({ layer, RectangleMarker }, (view) => { + if (!settingsStore.editorSettings.showCurrentStatementFrame) return null; + if (view.state.selection.ranges.some((range) => !range.empty)) return null; + let range = currentExecutableStatementRange(view); + if (!range) { + const cursorPos = view.state.selection.main.head; + const cursorLine = view.state.doc.lineAt(cursorPos); + executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions()); + // Find ranges that overlap the cursor line, then expand to include + // adjacent ranges (handles parser fragments from edge cases like + // ultra-long comments splitting a statement). + let mergedFrom = cursorLine.from; + let mergedTo = cursorLine.from; + let changed = true; + while (changed) { + changed = false; + for (const cachedRange of executableStatementRangeCache.ranges) { + // Only merge ranges that overlap the cursor line or are adjacent + // to the current merged region + if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) { + const newFrom = Math.min(mergedFrom, cachedRange.from); + const newTo = Math.max(mergedTo, cachedRange.to); + if (newFrom !== mergedFrom || newTo !== mergedTo) { + mergedFrom = newFrom; + mergedTo = newTo; + changed = true; } } - } catch { - insertValueHints = []; } - let maxWidth = 1; - for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) { - const line = view.state.doc.line(lineNumber); - const lineRangeTo = Math.min(line.to, frameTo); - maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo, insertValueHints)); - } - - const deco: any[] = []; - const frameWidth = `calc(${maxWidth}ch + 2ch)`; - for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) { - const line = view.state.doc.line(lineNumber); - const classes = ["cm-db-current-statement-line"]; - if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first"); - if (lineNumber === endLine.number) classes.push("cm-db-current-statement-line--last"); - deco.push( - Decoration.line({ - class: classes.join(" "), - attributes: { - style: `--dbx-current-statement-frame-width: ${frameWidth};`, - }, - }).range(line.from), - ); - } - return Decoration.set(deco); } - }, - { decorations: (v) => v.decorations }, - ); + if (mergedTo > mergedFrom) { + range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) }; + } + } + if (!range) return null; + return { from: range.from, to: currentStatementFrameTo(view, range) }; + }); function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number { return currentStatementFrameRangeTo(view.state.doc, range); @@ -4374,7 +4366,7 @@ onMounted(async () => { mousedown: selectSqlLineFromGutter, }, }), - currentStatementFrameHighlighter, + currentStatementFrameExtension, highlightActiveLineGutter(), highlightSpecialChars(), history(), @@ -5299,29 +5291,15 @@ defineExpose({ color: rgb(216 180 254) !important; } -:deep(.cm-db-current-statement-line) { - position: relative; -} - -:deep(.cm-db-current-statement-line::after) { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - box-sizing: border-box; - width: var(--dbx-current-statement-frame-width, 100%); - border-right: 1px solid rgb(34 197 94 / 0.75); - border-left: 1px solid rgb(34 197 94 / 0.75); +:deep(.cm-db-currentStatementFrameLayer) { pointer-events: none; } -:deep(.cm-db-current-statement-line--first::after) { - border-top: 1px solid rgb(34 197 94 / 0.75); -} - -:deep(.cm-db-current-statement-line--last::after) { - border-bottom: 1px solid rgb(34 197 94 / 0.75); +:deep(.cm-db-currentStatementFrame) { + box-sizing: border-box; + border: 1px solid rgb(34 197 94 / 0.75); + border-radius: 2px; + pointer-events: none; } :deep(.cm-run-statement-gutter) { diff --git a/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts b/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts new file mode 100644 index 000000000..22c4eae7a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { Text } from "@codemirror/state"; +import { currentStatementFrameRect, FRAME_INSET_PX } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; + +interface CoordRect { + left: number; + right: number; + top: number; + bottom: number; + height: number; +} + +interface LineBlock { + from: number; + length: number; + top: number; + bottom: number; + height: number; +} + +interface ViewLike { + coordsAtPos: (pos: number, side?: -1 | 1) => CoordRect | null; + lineBlockAt: (pos: number) => LineBlock; + defaultCharacterWidth: number; + state: { doc: ReturnType }; + scrollDOM: { scrollLeft: number; scrollTop: number; getBoundingClientRect(): DOMRect }; + scaleX: number; + scaleY: number; +} + +const CHAR_WIDTH = 9; +const LINE_HEIGHT = 20; +const BASE_TOP = 112; // typical editor top on screen + +function buildView(lines: string[], scrollTop = 0, coordsAtPosNull: ((pos: number, side: -1 | 1) => boolean) | null = null): ViewLike { + const doc = Text.of(lines); + const lineTop = (pos: number) => { + const line = doc.lineAt(pos); + return (line.number - 1) * LINE_HEIGHT; + }; + return { + defaultCharacterWidth: CHAR_WIDTH, + lineBlockAt(pos: number): LineBlock { + const top = lineTop(pos); + const line = doc.lineAt(pos); + return { from: line.from, length: line.length, top, bottom: top + LINE_HEIGHT, height: LINE_HEIGHT }; + }, + state: { doc }, + scaleX: 1, + scaleY: 1, + scrollDOM: { + scrollLeft: 0, + scrollTop, + getBoundingClientRect: () => ({ left: 0, top: BASE_TOP, right: 1000, bottom: 1000, width: 1000, height: 1000 }) as DOMRect, + }, + coordsAtPos(pos: number, side: -1 | 1 = 1): CoordRect | null { + if (coordsAtPosNull?.(pos, side)) return null; + const col = pos - doc.lineAt(pos).from; + const left = col * CHAR_WIDTH; + const top = lineTop(pos); + // Screen coords = content pos - scrollTop + baseTop + return side === -1 + ? { left: left - CHAR_WIDTH, right: left, top: top - scrollTop + BASE_TOP, bottom: top + LINE_HEIGHT - scrollTop + BASE_TOP, height: LINE_HEIGHT } + : { left, right: left + CHAR_WIDTH, top: top - scrollTop + BASE_TOP, bottom: top + LINE_HEIGHT - scrollTop + BASE_TOP, height: LINE_HEIGHT }; + }, + }; +} + +describe("currentStatementFrameRect", () => { + it("returns null for an empty or invalid range", () => { + const view = buildView(["SELECT 1"]); + expect(currentStatementFrameRect(view, 3, 3)).toBeNull(); + expect(currentStatementFrameRect(view, 5, 2)).toBeNull(); + expect(currentStatementFrameRect(view, 0, 99)).toBeNull(); + expect(currentStatementFrameRect(view, 4, 8)).not.toBeNull(); + }); + + it("uses coordsAtPos for vertical bounds", () => { + const view = buildView(["SELECT 1"]); + const rect = currentStatementFrameRect(view, 0, 8); + expect(rect).not.toBeNull(); + expect(rect!.left).toBe(0 - FRAME_INSET_PX); + expect(rect!.width).toBe(8 * CHAR_WIDTH + FRAME_INSET_PX * 2); + expect(rect!.top).toBe(0 - FRAME_INSET_PX); + expect(rect!.height).toBe(LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("spans a multi-line statement as one continuous rectangle", () => { + const view = buildView(["SELECT a,", "b", "FROM t"]); + const to = "SELECT a,\nb\nFROM t".length; + const rect = currentStatementFrameRect(view, 0, to); + expect(rect).not.toBeNull(); + expect(rect!.top).toBe(0 - FRAME_INSET_PX); + expect(rect!.height).toBe(3 * LINE_HEIGHT + FRAME_INSET_PX * 2); + expect(rect!.width).toBe(9 * CHAR_WIDTH + FRAME_INSET_PX * 2); + }); + + it("frames a statement interrupted by a blank line", () => { + const view = buildView(["SELECT a", "", "FROM t"]); + const rect = currentStatementFrameRect(view, 0, view.state.doc.length); + expect(rect).not.toBeNull(); + expect(rect!.top).toBe(0 - FRAME_INSET_PX); + expect(rect!.height).toBe(3 * LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("bottoms at the last statement line, not the next line", () => { + const view = buildView(["SELECT 1;", "SELECT 2"]); + const rect = currentStatementFrameRect(view, 0, 9); + expect(rect).not.toBeNull(); + expect(rect!.height).toBe(LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("produces the same frame regardless of scroll position", () => { + const lines = ["-- header", "SELECT a,", "b,", "c,", "FROM t;"]; + const from = Text.of(lines).line(2).from; + const to = Text.of(lines).line(5).to; + + // No scroll + const view1 = buildView(lines, 0); + const rect1 = currentStatementFrameRect(view1, from, to); + expect(rect1).not.toBeNull(); + expect(rect1!.top + FRAME_INSET_PX).toBe(LINE_HEIGHT); + expect(rect1!.height).toBe(4 * LINE_HEIGHT + FRAME_INSET_PX * 2); + + // Scrolled down 200px + const view2 = buildView(lines, 200); + const rect2 = currentStatementFrameRect(view2, from, to); + expect(rect2).not.toBeNull(); + expect(rect2!.top).toBe(rect1!.top); + expect(rect2!.height).toBe(rect1!.height); + }); + + it("falls back to lineBlockAt when coordsAtPos returns null for off-viewport start", () => { + // Statement spans lines 2-5. coordsAtPos returns null for the start (far off-screen above). + const lines = ["-- header", "SELECT a,", "b,", "c,", "FROM t;"]; + const from = Text.of(lines).line(2).from; + const to = Text.of(lines).line(5).to; + + const view = buildView(lines, 200, (pos, side) => pos === from && side === 1); + + const rect = currentStatementFrameRect(view, from, to); + expect(rect).not.toBeNull(); + // Fallback: lineBlockAt(from).top = 1 * LINE_HEIGHT = 20 + expect(rect!.top + FRAME_INSET_PX).toBe(LINE_HEIGHT); + // Bottom uses coordsAtPos: (4 * LINE_HEIGHT) - 200 + BASE_TOP - BASE_TOP + 200 = 4 * LINE_HEIGHT + expect(rect!.height).toBe(4 * LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("handles both start and end off-viewport (deep scroll)", () => { + // 100 lines; statement spans lines 48-73. Scroll so both are off-screen. + const lines: string[] = []; + for (let i = 1; i <= 100; i++) { + if (i === 48) lines.push("CREATE TABLE t ("); + else if (i >= 49 && i <= 72) lines.push(" col" + i + " INT,"); + else if (i === 73) lines.push(") ENGINE=InnoDB;"); + else lines.push("-- line " + i); + } + const from = Text.of(lines).line(48).from; + const to = Text.of(lines).line(73).to; + + // coordsAtPos returns null for both start and end (far off-screen) + const view = buildView(lines, 2000, (pos, side) => (pos === from && side === 1) || (pos === to - 1 && side === -1)); + + const rect = currentStatementFrameRect(view, from, to); + expect(rect).not.toBeNull(); + // top = lineBlockAt(48).top = 47 * LINE_HEIGHT + expect(rect!.top + FRAME_INSET_PX).toBe(47 * LINE_HEIGHT); + // bottom = lineBlockAt(73).bottom = 73 * LINE_HEIGHT + const expectedHeight = (73 - 47) * LINE_HEIGHT + FRAME_INSET_PX * 2; + expect(rect!.height).toBe(expectedHeight); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorScrollPastEnd.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorScrollPastEnd.spec.ts index df05e7d4e..873a2f0ff 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorScrollPastEnd.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorScrollPastEnd.spec.ts @@ -5,7 +5,8 @@ const queryEditorSource = readFileSync(new URL("../../../components/editor/Query describe("QueryEditor bottom scroll space", () => { it("allows editable documents to scroll past the last line without changing read-only previews", () => { - expect(queryEditorSource).toContain("scrollPastEnd, ViewPlugin"); + expect(queryEditorSource).toMatch(/scrollPastEnd,\s*ViewPlugin/); + expect(queryEditorSource).toMatch(/layer,\s*RectangleMarker/); expect(queryEditorSource).toContain("props.readOnly ? [] : scrollPastEnd()"); }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts index e1667bfce..cd5987836 100644 --- a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, shouldRebuildCurrentStatementFrame, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; +import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame"; import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges"; function frameDocument(sql: string) { @@ -40,56 +40,3 @@ describe("currentStatementFrameRangeTo", () => { expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(range.to); }); }); - -describe("shouldRebuildCurrentStatementFrame", () => { - it("reuses frame decorations for pure viewport updates", () => { - expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: false, configurationChanged: false })).toBe(false); - }); - - it("rebuilds after document, selection, or configuration updates", () => { - expect(shouldRebuildCurrentStatementFrame({ docChanged: true, selectionSet: false, configurationChanged: false })).toBe(true); - expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: true, configurationChanged: false })).toBe(true); - expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: false, configurationChanged: true })).toBe(true); - }); -}); - -describe("visualSqlColumns", () => { - it("counts ASCII as one column, tabs as four, and CJK/fullwidth characters as two", () => { - expect(visualSqlColumns("A\t中B")).toBe(1 + 4 + 2 + 2); - }); - - it("recognizes common wide SQL text characters", () => { - expect(isWideSqlChar("中")).toBe(true); - expect(isWideSqlChar("A")).toBe(true); - expect(isWideSqlChar("A")).toBe(false); - }); -}); - -describe("visualSqlColumnsWithInlineHints", () => { - it("adds estimated columns for insert-value hints on the line", () => { - const text = "VALUES (12, 'a')"; - const lineFrom = 0; - const lineTo = text.length; - const withoutHints = visualSqlColumns(text); - const withHints = visualSqlColumnsWithInlineHints(text, lineFrom, lineTo, [ - { from: 8, column: "id" }, - { from: 12, column: "name" }, - ]); - expect(withHints).toBe(withoutHints + estimateInlineHintVisualColumns("id") + estimateInlineHintVisualColumns("name")); - }); - - it("ignores hints that belong to other lines", () => { - const text = "VALUES (1)"; - expect(visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 100, column: "id" }])).toBe(visualSqlColumns(text)); - }); - - it("dedupes hints that share the same document offset", () => { - const text = "VALUES (1)"; - const once = visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 8, column: "id" }]); - const twice = visualSqlColumnsWithInlineHints(text, 0, text.length, [ - { from: 8, column: "id" }, - { from: 8, column: "id" }, - ]); - expect(twice).toBe(once); - }); -}); diff --git a/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts b/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts new file mode 100644 index 000000000..ae00a69ac --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts @@ -0,0 +1,131 @@ +import type { EditorView, LayerMarker, ViewUpdate } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; + +export interface StatementFrameRequest { + /** Document range covered by the frame; null hides the frame. */ + from: number; + to: number; +} + +export type StatementFrameResolver = (view: EditorView) => StatementFrameRequest | null; + +/** Breathing room between the measured text bounds and the frame border (pixels). */ +export const FRAME_INSET_PX = 2; + +export interface FrameRect { + left: number; + top: number; + width: number; + height: number; +} + +type Viewish = Pick; + +/** + * Measure the pixel rectangle of one statement rendered in `view`. + * + * The frame is a single continuous rectangle: it starts at the first + * statement character, spans every statement line (blank and comment lines + * included), and ends at the widest line. + * + * **Vertical bounds**: use `lineBlockAt` (document-level coordinates, + * always reliable regardless of viewport position). + * + * **Horizontal bounds**: use `coordsAtPos` for visible lines (exact on tabs, + * CJK/fullwidth glyphs and non-monospaced fonts). For off-viewport lines, + * estimate using an offset calibrated from the first visible line's + * `coordsAtPos` result, so estimates account for editor padding, gutter + * width, and zoom. + * + * All four sides get a symmetric `FRAME_INSET_PX` breathing room. + */ +export function currentStatementFrameRect(view: Viewish, from: number, to: number): FrameRect | null { + const doc = view.state.doc; + if (!doc || to < from || from > doc.length || to > doc.length || to === from) return null; + + const startLine = doc.lineAt(from); + const endLine = doc.lineAt(to); + const base = view.scrollDOM.getBoundingClientRect(); + + // Vertical bounds: convert screen coords to content-layer coords. + // Primary: coordsAtPos (CodeMirror extrapolates for off-viewport lines). + // Fallback: lineBlockAt — returns content-layer coords directly (no + // scrollTop adjustment needed; the layer scrolls with the content). + + const startCoords = view.coordsAtPos(from, 1); + const top = startCoords ? startCoords.top - base.top + view.scrollDOM.scrollTop : view.lineBlockAt(from).top; + + const endCoords = view.coordsAtPos(Math.max(from, to - 1), -1); + const bottom = endCoords ? endCoords.bottom - base.top + view.scrollDOM.scrollTop : view.lineBlockAt(to).bottom; + + if (bottom - top <= 0) return null; + + // Horizontal bounds: use coordsAtPos for visible lines. For off-viewport + // lines, estimate using an offset calibrated from the first visible line. + let left = Infinity; + let right = -Infinity; + const charWidth = view.defaultCharacterWidth; + let calibratedOffset: number | null = null; + + for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) { + const line = doc.line(lineNumber); + const anchorFrom = lineNumber === startLine.number ? from : line.from; + const anchorTo = Math.min(line.to, to); + const lineLeft = view.coordsAtPos(anchorFrom, 1); + const lineRight = view.coordsAtPos(anchorTo, -1); + + if (lineLeft) { + left = Math.min(left, lineLeft.left); + right = Math.max(right, lineRight?.right ?? lineLeft.left); + // Calibrate offset from the first visible line + if (calibratedOffset === null && charWidth > 0) { + const indent = line.text.match(/^\s*/)?.[0].length || 0; + calibratedOffset = lineLeft.left - indent * charWidth; + } + } else if (calibratedOffset !== null && charWidth > 0) { + // Estimate: use calibrated offset + (indent + text.length) * charWidth + const indent = line.text.match(/^\s*/)?.[0].length || 0; + const estimatedRight = calibratedOffset + (indent + line.text.length) * charWidth; + right = Math.max(right, estimatedRight); + } + } + + // If no lines were measurable (entire statement off-viewport), skip frame + if (!isFinite(left) || !isFinite(right)) return null; + + const leftOffset = base.left - view.scrollDOM.scrollLeft * view.scaleX; + + return { + left: left - leftOffset - FRAME_INSET_PX, + top: top - FRAME_INSET_PX, + width: right - left + FRAME_INSET_PX * 2, + height: bottom - top + FRAME_INSET_PX * 2, + }; +} + +interface CurrentStatementFrameModule { + layer: (config: { above: boolean; class?: string; markers(view: EditorView): readonly LayerMarker[]; update(update: ViewUpdate, layer: HTMLElement): boolean }) => Extension; + RectangleMarker: new (className: string, left: number, top: number, width: number | null, height: number) => LayerMarker; +} + +/** + * Draw one continuous green rectangle around the current executable + * statement. `resolve` decides visibility and returns the statement range; + * returning null hides the frame. + */ +export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModule, resolve: StatementFrameResolver): Extension { + return viewModule.layer({ + above: false, + class: "cm-db-currentStatementFrameLayer", + markers(view) { + const request = resolve(view); + if (!request || request.to < request.from) return []; + const rect = currentStatementFrameRect(view, request.from, request.to); + if (!rect) return []; + return [new viewModule.RectangleMarker("cm-db-currentStatementFrame", rect.left, rect.top, rect.width, rect.height)]; + }, + update(update) { + return update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged || update.transactions.some((transaction) => transaction.reconfigured); + }, + }); +} diff --git a/apps/desktop/src/lib/sql/currentStatementFrame.ts b/apps/desktop/src/lib/sql/currentStatementFrame.ts index a109ea9dc..385826b66 100644 --- a/apps/desktop/src/lib/sql/currentStatementFrame.ts +++ b/apps/desktop/src/lib/sql/currentStatementFrame.ts @@ -5,61 +5,3 @@ export function currentStatementFrameRangeTo(doc: StatementDelimiterDocument, ra const delimiterPos = trailingStatementDelimiterPosition(doc, range.to); return delimiterPos === null ? range.to : delimiterPos + 1; } - -export function shouldRebuildCurrentStatementFrame(update: { docChanged: boolean; selectionSet: boolean; configurationChanged: boolean }): boolean { - return update.docChanged || update.selectionSet || update.configurationChanged; -} - -export function visualSqlColumns(text: string): number { - let columns = 0; - for (const ch of text) { - if (ch === "\t") { - columns += 4; - } else if (isWideSqlChar(ch)) { - columns += 2; - } else { - columns += 1; - } - } - return columns; -} - -export function isWideSqlChar(ch: string): boolean { - return /[\u1100-\u115f\u2329\u232a\u2e80-\u303e\u3040-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/u.test(ch); -} - -/** Approximate visual columns for a `.cm-insert-value-hint` widget (0.85em text + padding/margin). */ -export function estimateInlineHintVisualColumns(label: string): number { - // Slightly generous vs CSS (0.85em font + 0.3em*2 padding + 0.35em margin) so the - // statement frame does not clip past inlay hints. - return Math.max(2, Math.ceil(label.length * 0.9 + 2.2)); -} - -export interface InlineHintForFrameWidth { - from: number; - column: string; -} - -/** Document text columns plus inline widget hints that sit on `[lineFrom, lineTo)`. */ -export function visualSqlColumnsWithInlineHints(text: string, lineFrom: number, lineTo: number, hints: readonly InlineHintForFrameWidth[] = []): number { - let columns = visualSqlColumns(text); - const seen = new Set(); - for (const hint of hints) { - if (hint.from < lineFrom || hint.from >= lineTo) continue; - if (seen.has(hint.from)) continue; - seen.add(hint.from); - columns += estimateInlineHintVisualColumns(hint.column); - } - return columns; -} - -/** Measure rendered line width in CSS pixels, including inline widgets already in the DOM. */ -export function measureSqlLineWidthPx(view: { coordsAtPos: (pos: number, side?: -1 | 1) => { left: number; right: number; top: number; bottom: number } | null }, from: number, to: number): number | null { - if (to < from) return null; - const start = view.coordsAtPos(from, 1); - const end = view.coordsAtPos(to, -1); - if (!start || !end) return null; - // Ignore cross-line measurements (wrapping / mismatched sides). - if (Math.abs(start.top - end.top) > 2) return null; - return Math.max(0, end.right - start.left); -}