fix(editor): rebuild current SQL statement frame with pixel-accurate layer

This commit is contained in:
azens 2026-08-09 19:51:12 +08:00 committed by GitHub
parent 7c2528d32f
commit a4129c02fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 397 additions and 269 deletions

View File

@ -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<typeof import("@codemirror/view"), "Decoration" | "EditorView" | "ViewPlugin">, enabled: boolean) {
function buildPreviewCurrentStatementFrameExtension(viewModule: Pick<typeof import("@codemirror/view"), "EditorView" | "layer" | "RectangleMarker">, 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();

View File

@ -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) {

View File

@ -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<typeof Text.of> };
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);
});
});

View File

@ -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()");
});
});

View File

@ -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中")).toBe(1 + 4 + 2 + 2);
});
it("recognizes common wide SQL text characters", () => {
expect(isWideSqlChar("中")).toBe(true);
expect(isWideSqlChar("")).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);
});
});

View File

@ -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<EditorView, "coordsAtPos" | "state" | "scrollDOM" | "scaleX" | "lineBlockAt" | "defaultCharacterWidth">;
/**
* 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);
},
});
}

View File

@ -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<number>();
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);
}