From f6bb56dc2f8c18a09506112ad64619ec79bce629 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 20 Jul 2026 12:29:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E5=90=88=E5=B9=B6=E8=AF=AD?= =?UTF-8?q?=E5=8F=A5=E6=89=A7=E8=A1=8C=E7=8A=B6=E6=80=81=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/editor/QueryEditor.vue | 178 +++++++++--------- .../codemirrorStatementGutter.spec.ts | 40 ++++ .../lib/editor/codemirrorStatementGutter.ts | 67 +++++++ apps/desktop/src/lib/editor/editorThemes.ts | 28 ++- 4 files changed, 219 insertions(+), 94 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts create mode 100644 apps/desktop/src/lib/editor/codemirrorStatementGutter.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 67ae58e9d7..2710225559 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -56,7 +56,8 @@ import { type QueryEditorTableReferenceDropDetail, type QueryEditorTableReferencePayload, } from "@/lib/editor/queryEditorTableDrop"; -import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes"; +import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes"; +import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; @@ -261,7 +262,6 @@ let codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrac let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null; let readOnlyComp: import("@codemirror/state").Compartment | null = null; let runGutterComp: import("@codemirror/state").Compartment | null = null; -let statementExecutionGutterComp: import("@codemirror/state").Compartment | null = null; let runKeymapComp: import("@codemirror/state").Compartment | null = null; let completionComp: import("@codemirror/state").Compartment | null = null; let diagnosticComp: import("@codemirror/state").Compartment | null = null; @@ -310,8 +310,6 @@ let setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectT let previewRangeComp: import("@codemirror/state").Compartment | null = null; let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null; let buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null = null; -let buildStatementExecutionMarkersExtension: (() => import("@codemirror/state").Extension) | null = null; -let statementExecutionGutterMounted = false; let buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null = null; let indentComp: import("@codemirror/state").Compartment | null = null; let codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null = null; @@ -324,6 +322,11 @@ let tableReferenceDropListenerRegistered = false; let imeCompositionActive = false; let pendingImeModelEmit = false; +function runStatementGutterExtension(markers = props.statementExecutionMarkers ?? []): import("@codemirror/state").Extension { + const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons; + return shouldShowStatementGutter(showRunButtons, markers.length) ? (buildRunStatementGutterExtension?.() ?? []) : []; +} + type SelectionCaseMode = "upper" | "lower"; let executableStatementRangeCache: ExecutableStatementRangeCache | null = null; let editorScrollbarPointerCleanup: (() => void) | null = null; @@ -3004,7 +3007,6 @@ onMounted(async () => { codeMirrorCloseBracketsKeymap = closeBracketsKeymap; readOnlyComp = new Compartment(); runGutterComp = new Compartment(); - statementExecutionGutterComp = new Compartment(); runKeymapComp = new Compartment(); completionComp = new Compartment(); diagnosticComp = new Compartment(); @@ -3139,23 +3141,35 @@ onMounted(async () => { return field; }; - class StatementExecutionStatusMarker extends GutterMarker { + class StatementExecutionStateMarker extends GutterMarker { constructor(readonly marker: StatementExecutionMarker) { super(); } eq(other: import("@codemirror/view").GutterMarker): boolean { - return other instanceof StatementExecutionStatusMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount; + return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount; + } + } + + class StatementGutterMarker extends GutterMarker { + constructor( + readonly canExecute: boolean, + readonly marker?: StatementExecutionMarker, + ) { + super(); + } + + eq(other: import("@codemirror/view").GutterMarker): boolean { + return other instanceof StatementGutterMarker && other.canExecute === this.canExecute && other.marker?.status === this.marker?.status && other.marker?.successCount === this.marker?.successCount && other.marker?.errorCount === this.marker?.errorCount; } toDOM() { - const element = document.createElement("span"); - const title = statementExecutionMarkerTitle(this.marker); - element.className = `cm-statement-execution-marker cm-statement-execution-marker--${this.marker.status}`; - element.title = title; - element.setAttribute("aria-label", title); - element.appendChild(createStatementExecutionStatusIconDom(this.marker.status)); - return element; + return createStatementGutterMarkerDom({ + canExecute: this.canExecute, + executeLabel: t("editor.contextMenu.executeCurrent"), + status: this.marker?.status, + statusLabel: this.marker ? statementExecutionMarkerTitle(this.marker) : undefined, + }); } } @@ -3166,34 +3180,14 @@ onMounted(async () => { return parts.join(", "); } - function createStatementExecutionStatusIconDom(status: StatementExecutionMarker["status"]) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("fill", "none"); - svg.setAttribute("stroke", "currentColor"); - svg.setAttribute("stroke-width", "2"); - svg.setAttribute("stroke-linecap", "round"); - svg.setAttribute("stroke-linejoin", "round"); - svg.setAttribute("aria-hidden", "true"); - - const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circle.setAttribute("cx", "12"); - circle.setAttribute("cy", "12"); - circle.setAttribute("r", "10"); - const mark = document.createElementNS("http://www.w3.org/2000/svg", "path"); - mark.setAttribute("d", status === "error" ? "m15 9-6 6m0-6 6 6" : "m9 12 2 2 4-4"); - svg.append(circle, mark); - - return svg; - } - setStatementExecutionMarkersEffect = StateEffect.define(); - buildStatementExecutionMarkersExtension = () => { + buildRunStatementGutterExtension = () => { const effectType = setStatementExecutionMarkersEffect!; + const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons; const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => { const ranges = markers.map((marker) => { const from = Math.max(0, Math.min(marker.from, state.doc.length)); - return new StatementExecutionStatusMarker(marker).range(state.doc.lineAt(from).from); + return new StatementExecutionStateMarker(marker).range(state.doc.lineAt(from).from); }); return RangeSet.of(ranges, true); }; @@ -3211,14 +3205,22 @@ onMounted(async () => { }, provide: (field) => gutter({ - class: "cm-statement-execution-gutter", + class: "cm-run-statement-gutter", markers: (currentView) => currentView.state.field(field), + lineMarker(currentView, line, markers) { + const executionMarker = markers.find((marker) => marker instanceof StatementExecutionStateMarker)?.marker; + const canExecute = showRunButtons && !!executableStatementRangeStartingAt(currentView, line.from); + return canExecute || executionMarker ? new StatementGutterMarker(canExecute, executionMarker) : null; + }, + domEventHandlers: showRunButtons + ? { + mousedown: executeSqlStatementFromGutter, + } + : {}, }), }); return field; }; - statementExecutionGutterMounted = (props.statementExecutionMarkers?.length ?? 0) > 0; - buildSqlSignatureExtension = () => showTooltip.compute(["doc", "selection"], (currentState) => { const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head, props.databaseType); @@ -3292,26 +3294,6 @@ onMounted(async () => { await ensureCodeMirrorVim(); } - class RunStatementGutterMarker extends GutterMarker { - toDOM() { - return createRunStatementButtonDom("Execute statement"); - } - } - - const executableStatementMarker = new RunStatementGutterMarker(); - buildRunStatementGutterExtension = () => - settingsStore.editorSettings.showStatementRunButtons - ? gutter({ - class: "cm-run-statement-gutter", - lineMarker(currentView, line) { - return executableStatementRangeStartingAt(currentView, line.from) ? executableStatementMarker : null; - }, - domEventHandlers: { - mousedown: executeSqlStatementFromGutter, - }, - }) - : []; - const currentStatementFrameHighlighter = ViewPlugin.fromClass( class { decorations: import("@codemirror/view").DecorationSet; @@ -3414,7 +3396,7 @@ onMounted(async () => { return { dom }; }, }), - runGutterComp.of(props.hideExecutionControls ? [] : buildRunStatementGutterExtension()), + runGutterComp.of(runStatementGutterExtension()), lineNumbers({ domEventHandlers: { mousedown: selectSqlLineFromGutter, @@ -3485,7 +3467,6 @@ onMounted(async () => { }), previewRangeComp.of(buildPreviewRangeExtension()), buildResultSourceRangeExtension(), - statementExecutionGutterComp.of(statementExecutionGutterMounted ? buildStatementExecutionMarkersExtension() : []), Prec.highest( keymap.of([ { key: "'", run: handleSqlSingleQuote }, @@ -3792,22 +3773,7 @@ watch( watch( () => props.statementExecutionMarkers ?? [], (markers) => { - if (!view.value || !statementExecutionGutterComp || !buildStatementExecutionMarkersExtension || !setStatementExecutionMarkersEffect) return; - if (markers.length === 0) { - if (!statementExecutionGutterMounted) return; - view.value.dispatch({ - effects: statementExecutionGutterComp.reconfigure([]), - }); - statementExecutionGutterMounted = false; - return; - } - if (!statementExecutionGutterMounted) { - view.value.dispatch({ - effects: statementExecutionGutterComp.reconfigure(buildStatementExecutionMarkersExtension()), - }); - statementExecutionGutterMounted = true; - return; - } + if (!view.value || !setStatementExecutionMarkersEffect) return; view.value.dispatch({ effects: setStatementExecutionMarkersEffect.of(markers), }); @@ -3815,6 +3781,15 @@ watch( { deep: true }, ); +watch( + () => props.statementExecutionMarkers?.length ?? 0, + () => { + if (!view.value || !runGutterComp) return; + // Remove the compartment entirely when controls and markers are absent so the editor keeps no empty gutter width. + view.value.dispatch({ effects: runGutterComp.reconfigure(runStatementGutterExtension()) }); + }, +); + watch( () => props.connectionId, () => { @@ -3891,7 +3866,7 @@ watch( wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)), closeBracketsComp.reconfigure(closeBracketsExtension(settingsStore.editorSettings.autoCloseBrackets)), - runGutterComp.reconfigure(props.hideExecutionControls ? [] : (buildRunStatementGutterExtension?.() ?? [])), + runGutterComp.reconfigure(runStatementGutterExtension()), runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)), ], }); @@ -4198,19 +4173,6 @@ defineExpose({ padding: 0 5px; } -:deep(.cm-statement-execution-gutter) { - min-width: 34px; -} - -:deep(.cm-statement-execution-gutter .cm-gutterElement) { - align-items: center; - box-sizing: border-box; - display: flex; - justify-content: center; - min-width: 34px; - padding: 0 5px; -} - :deep(.cm-statement-execution-marker) { display: inline-flex; align-items: center; @@ -4258,6 +4220,7 @@ defineExpose({ } :deep(.cm-run-statement-marker) { + position: relative; display: inline-flex; align-items: center; justify-content: center; @@ -4299,7 +4262,7 @@ defineExpose({ color: rgb(167 243 208); } -:deep(.cm-run-statement-marker svg) { +:deep(.cm-run-statement-marker > svg) { display: block; width: min(14px, 70%); height: min(14px, 70%); @@ -4307,6 +4270,35 @@ defineExpose({ flex-shrink: 0; } +:deep(.cm-statement-execution-badge) { + position: absolute; + right: -1px; + bottom: -1px; + display: inline-flex; + align-items: center; + justify-content: center; + width: min(9px, 45%); + height: min(9px, 45%); + border-radius: 9999px; + color: white; + box-shadow: 0 0 0 1px rgb(255 255 255 / 0.9); + pointer-events: none; +} + +:deep(.cm-statement-execution-badge--success) { + background: rgb(5 150 105); +} + +:deep(.cm-statement-execution-badge--error) { + background: rgb(220 38 38); +} + +:deep(.cm-statement-execution-badge svg) { + display: block; + width: 75%; + height: 75%; +} + :deep(.cm-foldMarker-svg) { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts b/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts new file mode 100644 index 0000000000..7f12a1be81 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts @@ -0,0 +1,40 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; + +describe("CodeMirror statement gutter marker", () => { + it("omits the gutter when run buttons and status markers are absent", () => { + expect(shouldShowStatementGutter(false, 0)).toBe(false); + expect(shouldShowStatementGutter(true, 0)).toBe(true); + expect(shouldShowStatementGutter(false, 1)).toBe(true); + }); + + it("keeps execution status inside the run button column", () => { + const marker = createStatementGutterMarkerDom({ + canExecute: true, + executeLabel: "Execute SQL", + status: "success", + statusLabel: "1 statement succeeded", + }); + + expect(marker.tagName).toBe("BUTTON"); + expect(marker.classList.contains("cm-run-statement-marker")).toBe(true); + expect(marker.querySelectorAll(".cm-statement-execution-badge--success")).toHaveLength(1); + expect(marker.getAttribute("aria-label")).toBe("Execute SQL. 1 statement succeeded"); + }); + + it("renders a standalone status marker when run buttons are disabled", () => { + const marker = createStatementGutterMarkerDom({ + canExecute: false, + executeLabel: "Execute SQL", + status: "error", + statusLabel: "1 statement failed", + }); + + expect(marker.tagName).toBe("SPAN"); + expect(marker.classList.contains("cm-statement-execution-marker--error")).toBe(true); + expect(marker.querySelectorAll(".cm-statement-execution-badge")).toHaveLength(0); + expect(marker.getAttribute("aria-label")).toBe("1 statement failed"); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts b/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts new file mode 100644 index 0000000000..413afa6c29 --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts @@ -0,0 +1,67 @@ +import { createRunStatementButtonDom } from "@/lib/editor/editorThemes"; +import type { StatementExecutionMarkerStatus } from "@/lib/tabs/tabPresentation"; + +export interface StatementGutterMarkerDomOptions { + canExecute: boolean; + executeLabel: string; + status?: StatementExecutionMarkerStatus; + statusLabel?: string; +} + +export function shouldShowStatementGutter(showRunButtons: boolean, markerCount: number): boolean { + return showRunButtons || markerCount > 0; +} + +function createStatusIconDom(status: StatementExecutionMarkerStatus, includeCircle: boolean) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", includeCircle ? "2" : "3.5"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("aria-hidden", "true"); + + if (includeCircle) { + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", "12"); + circle.setAttribute("cy", "12"); + circle.setAttribute("r", "10"); + svg.appendChild(circle); + } + + const mark = document.createElementNS("http://www.w3.org/2000/svg", "path"); + mark.setAttribute("d", status === "error" ? "m16 8-8 8m0-8 8 8" : "m7 12 3 3 7-7"); + svg.appendChild(mark); + return svg; +} + +export function createStatementGutterMarkerDom(options: StatementGutterMarkerDomOptions): HTMLElement { + const statusLabel = options.statusLabel?.trim(); + if (options.canExecute) { + const button = createRunStatementButtonDom(options.executeLabel); + if (!options.status) return button; + + const badge = document.createElement("span"); + badge.className = `cm-statement-execution-badge cm-statement-execution-badge--${options.status}`; + badge.setAttribute("aria-hidden", "true"); + badge.appendChild(createStatusIconDom(options.status, false)); + button.appendChild(badge); + + if (statusLabel) { + const label = `${options.executeLabel}. ${statusLabel}`; + button.title = label; + button.setAttribute("aria-label", label); + } + return button; + } + + const marker = document.createElement("span"); + marker.className = `cm-statement-execution-marker cm-statement-execution-marker--${options.status}`; + if (statusLabel) { + marker.title = statusLabel; + marker.setAttribute("aria-label", statusLabel); + } + if (options.status) marker.appendChild(createStatusIconDom(options.status, true)); + return marker; +} diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index bfd7d4b13c..6607d73a8a 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -790,6 +790,7 @@ export function buildEditorFontThemeRules(opts?: { fixedHeight?: boolean; scroll margin: "0", outline: "none", padding: "0", + position: "relative", transition: "color 0.15s, background-color 0.15s", userSelect: "none", verticalAlign: "middle", @@ -805,13 +806,38 @@ export function buildEditorFontThemeRules(opts?: { fixedHeight?: boolean; scroll background: "rgb(16 185 129 / 0.2)", color: "rgb(6 95 70)", }, - "&.cm-editor .cm-run-statement-marker svg": { + "&.cm-editor .cm-run-statement-marker > svg": { display: "block", flexShrink: "0", height: "min(14px, 70%)", pointerEvents: "none", width: "min(14px, 70%)", }, + ".cm-statement-execution-badge": { + alignItems: "center", + borderRadius: "9999px", + bottom: "-1px", + boxShadow: "0 0 0 1px rgb(255 255 255 / 0.9)", + color: "white", + display: "inline-flex", + height: "min(9px, 45%)", + justifyContent: "center", + pointerEvents: "none", + position: "absolute", + right: "-1px", + width: "min(9px, 45%)", + }, + ".cm-statement-execution-badge--success": { + background: "rgb(5 150 105)", + }, + ".cm-statement-execution-badge--error": { + background: "rgb(220 38 38)", + }, + ".cm-statement-execution-badge svg": { + display: "block", + height: "75%", + width: "75%", + }, "&.cm-editor.cm-focused .cm-run-statement-marker:focus-visible": { outline: "1px solid var(--ring)", outlineOffset: "1px",