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

This commit is contained in:
azens authored and GitHub committed 2026-08-09 19:51:12 +08:00
1 parent 7c2528d32f
commit a4129c02fa
7 files changed
+397 -269

No files matched your search

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