From 6d0a59a913a71bfc2f8a08e1b50d06f7415f026f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Mon, 6 Jul 2026 18:01:31 +0800 Subject: [PATCH] feat(editor): SQL editor execution button config toggle & optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SQL 编辑器的左侧添加每条语句的执行按钮 - 支持通过快捷键和右键菜单执行语句 - 增加多语言支持的相关文本描述 --- .../editor/EditorSettingsDialog.vue | 137 +++++++++++++++++- .../src/components/editor/QueryEditor.vue | 53 ++++--- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + apps/desktop/src/lib/editor/editorThemes.ts | 74 ++++++++++ apps/desktop/src/stores/settingsStore.ts | 4 + packages/app-tests/settingsStore.test.ts | 7 + 12 files changed, 266 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 45e29c7a9..169c67884 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -43,7 +43,7 @@ import { type CustomThemeColors, type CustomTheme, } from "@/stores/settingsStore"; -import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; +import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { formatAiModelOption } from "@/lib/ai/aiModelPresentation"; import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; @@ -74,6 +74,7 @@ import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; +import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "@/lib/table/tableColumnTemplates"; import { buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpVsCodeConfig, type McpEnvEntry, type McpLaunchConfig } from "@/lib/mcp/mcpConfigTemplates"; import { isWindows } from "@/lib/backend/platform"; @@ -236,6 +237,7 @@ const editActiveCustomThemeId = ref(settingsStore.editorSettings.activeCustomThe const showThemeCustomizer = ref(false); const editExecuteMode = ref(settingsStore.editorSettings.executeMode); const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker); +const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStatementRunButtons); const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); @@ -545,6 +547,7 @@ watch( editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId; editExecuteMode.value = settingsStore.editorSettings.executeMode; editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker; + editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons; editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editWordWrap.value = settingsStore.editorSettings.wordWrap; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; @@ -637,6 +640,7 @@ function hasChanges(): boolean { editActiveCustomThemeId.value !== settingsStore.editorSettings.activeCustomThemeId || editExecuteMode.value !== settingsStore.editorSettings.executeMode || editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker || + editShowStatementRunButtons.value !== settingsStore.editorSettings.showStatementRunButtons || editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables || editWordWrap.value !== settingsStore.editorSettings.wordWrap || editVimModeEnabled.value !== settingsStore.editorSettings.vimModeEnabled || @@ -696,6 +700,7 @@ async function persistSettings() { activeCustomThemeId: editActiveCustomThemeId.value, executeMode: editExecuteMode.value, showExecutionTargetPicker: editShowExecutionTargetPicker.value, + showStatementRunButtons: editShowStatementRunButtons.value, autoAliasTables: editAutoAliasTables.value, wordWrap: editWordWrap.value, vimModeEnabled: editVimModeEnabled.value, @@ -778,6 +783,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize; editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; + editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -847,6 +853,7 @@ function resetAllDefaults() { editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId; editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; + editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -1997,6 +2004,7 @@ const previewSettings = computed<{ appAppearance: AppThemeAppearance; appPalette: AppThemePalette; customColors?: CustomThemeColors; + showStatementRunButtons: boolean; }>(() => ({ fontFamily: editFontFamily.value, fontSize: editFontSize.value, @@ -2004,21 +2012,36 @@ const previewSettings = computed<{ appAppearance: isDark.value ? "dark" : "light", appPalette: themePalette.value, customColors: getPreviewCustomThemeColors(), + showStatementRunButtons: editShowStatementRunButtons.value, })); const previewSqlNormal = `SELECT u.id, u.name FROM users u -ORDER BY u.id LIMIT 5;`; +ORDER BY u.id LIMIT 5; + +SELECT o.id, o.total +FROM orders o +WHERE o.total > 100;`; const previewSqlWithSyntaxError = `SELECT u.id, u.name FOM users u -ORDER BY u.id LIMIT 5;`; +ORDER BY u.id LIMIT 5; + +SELECT o.id, o.total +FROM orders o +WHERE o.total > 100;`; let fontThemeComp: import("@codemirror/state").Compartment | null = null; let themeComp: import("@codemirror/state").Compartment | null = null; let diagnosticComp: import("@codemirror/state").Compartment | null = null; +let previewRunGutterComp: import("@codemirror/state").Compartment | null = null; let setPreviewDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; +let setPreviewRunHighlightEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null; let editorViewModule: typeof import("@codemirror/view") | null = null; let previewSqlDiagnostics: PreviewSqlDiagnostic[] = []; +let previewExecutableCache: ExecutableStatementRangeCache | null = null; +let previewRunHighlightRange: { from: number; to: number } | null = null; +let previewRunHighlightTimer: ReturnType | null = null; +let buildPreviewRunGutterExtension: () => import("@codemirror/state").Extension = () => []; function currentPreviewSql(): string { return editSqlSemanticDiagnosticsEnabled.value ? previewSqlWithSyntaxError : previewSqlNormal; @@ -2041,12 +2064,53 @@ function updatePreviewSqlDiagnostics() { view.dispatch({ effects }); return; } + previewExecutableCache = null; view.dispatch({ changes: { from: 0, to: currentSql.length, insert: nextSql }, effects, }); } +function previewExecutableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) { + previewExecutableCache = executableStatementRangeCacheForDoc(previewExecutableCache, currentView.state.doc, "mysql"); + return executableStatementRangeStartingAt(previewExecutableCache, lineFrom); +} + +function clearPreviewRunHighlight() { + previewRunHighlightRange = null; + if (previewView.value && setPreviewRunHighlightEffect) { + previewView.value.dispatch({ effects: setPreviewRunHighlightEffect.of(null) }); + } +} + +function flashPreviewRunHighlight(range: { from: number; to: number }, event: Event) { + previewRunHighlightRange = range; + if (previewView.value && setPreviewRunHighlightEffect) { + previewView.value.dispatch({ effects: setPreviewRunHighlightEffect.of(range) }); + } + if (event.target instanceof Element) { + const marker = event.target.closest(".cm-run-statement-marker"); + marker?.classList.add("cm-run-statement-marker--executed"); + window.setTimeout(() => marker?.classList.remove("cm-run-statement-marker--executed"), 650); + } + if (previewRunHighlightTimer) clearTimeout(previewRunHighlightTimer); + previewRunHighlightTimer = window.setTimeout(() => { + previewRunHighlightTimer = null; + clearPreviewRunHighlight(); + }, 650); +} + +function handlePreviewRunGutterMouseDown(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean { + if (!(event instanceof MouseEvent) || event.button !== 0) return false; + const statementRange = previewExecutableStatementRangeStartingAt(currentView, line.from); + if (!statementRange) return false; + event.preventDefault(); + event.stopPropagation(); + flashPreviewRunHighlight({ from: statementRange.from, to: statementRange.to }, event); + currentView.focus(); + return true; +} + watch( [previewSettings, editCustomThemes, editActiveCustomThemeId], async ([ss]) => { @@ -2054,7 +2118,7 @@ watch( const themeExt = await loadEditorTheme(ss.theme, ss.appAppearance, ss.customColors, ss.appPalette); previewView.value.dispatch({ - effects: [themeComp.reconfigure(themeExt), fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily))], + effects: [themeComp.reconfigure(themeExt), fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)), ...(previewRunGutterComp ? [previewRunGutterComp.reconfigure(buildPreviewRunGutterExtension())] : [])], }); }, { deep: true }, @@ -2074,9 +2138,18 @@ function cleanupPreviewEditor() { fontThemeComp = null; themeComp = null; diagnosticComp = null; + previewRunGutterComp = null; setPreviewDiagnosticsEffect = null; + setPreviewRunHighlightEffect = null; editorViewModule = null; previewSqlDiagnostics = []; + previewExecutableCache = null; + previewRunHighlightRange = null; + buildPreviewRunGutterExtension = () => []; + if (previewRunHighlightTimer) { + clearTimeout(previewRunHighlightTimer); + previewRunHighlightTimer = null; + } } watch(activeSettingsTab, (tab) => { @@ -2090,13 +2163,15 @@ watch(previewRef, async (el) => { previewInitialized = true; if (previewView.value) return; - const [{ EditorView, Decoration }, { 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, gutter, GutterMarker }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]); editorViewModule = { EditorView } as typeof import("@codemirror/view"); fontThemeComp = new Compartment(); themeComp = new Compartment(); diagnosticComp = new Compartment(); + previewRunGutterComp = new Compartment(); setPreviewDiagnosticsEffect = StateEffect.define(); + setPreviewRunHighlightEffect = StateEffect.define<{ from: number; to: number } | null>(); previewSqlDiagnostics = previewDiagnosticsForSql(currentPreviewSql()); const ss = previewSettings.value; @@ -2132,9 +2207,51 @@ watch(previewRef, async (el) => { return [field, diagnosticTheme]; }; + class PreviewRunStatementGutterMarker extends GutterMarker { + toDOM() { + return createRunStatementButtonDom(t("settings.previewStatementRunButton")); + } + } + + const previewRunMarker = new PreviewRunStatementGutterMarker(); + buildPreviewRunGutterExtension = () => + editShowStatementRunButtons.value + ? gutter({ + class: "cm-run-statement-gutter", + lineMarker(currentView, line) { + return previewExecutableStatementRangeStartingAt(currentView, line.from) ? previewRunMarker : null; + }, + domEventHandlers: { + mousedown: handlePreviewRunGutterMouseDown, + }, + }) + : []; + + const buildPreviewRunHighlightExtension = () => { + const highlightEffect = setPreviewRunHighlightEffect; + const buildDecorations = () => (previewRunHighlightRange ? Decoration.set([Decoration.mark({ class: "cm-settings-preview-run-highlight" }).range(previewRunHighlightRange.from, previewRunHighlightRange.to)], true) : Decoration.none); + const field = StateField.define({ + create: buildDecorations, + update(value, transaction) { + const highlightChanged = !!highlightEffect && transaction.effects.some((effect) => effect.is(highlightEffect)); + return transaction.docChanged || highlightChanged ? buildDecorations() : value; + }, + provide: (field) => EditorView.decorations.from(field), + }); + return field; + }; + const state = EditorState.create({ doc: currentPreviewSql(), - extensions: [basicSetup, sql({ dialect: MySQL }), themeComp.of(themeExt), fontThemeComp.of(editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)), diagnosticComp.of(buildPreviewDiagnosticExtension())], + extensions: [ + basicSetup, + sql({ dialect: MySQL }), + themeComp.of(themeExt), + fontThemeComp.of(editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)), + previewRunGutterComp.of(buildPreviewRunGutterExtension()), + diagnosticComp.of(buildPreviewDiagnosticExtension()), + buildPreviewRunHighlightExtension(), + ], }); previewView.value = new EditorView({ state, parent: previewRef.value }); @@ -2269,6 +2386,14 @@ onUnmounted(cleanupPreviewEditor); +
+
+ +

{{ t("settings.showStatementRunButtonsDescription") }}

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 010bf59c6..1f2e8cd08 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -47,7 +47,7 @@ import { type QueryEditorTableReferenceDropDetail, type QueryEditorTableReferencePayload, } from "@/lib/editor/queryEditorTableDrop"; -import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme } from "@/lib/editor/editorThemes"; +import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, createRunStatementButtonDom, loadEditorTheme, editorFontTheme, sqlCompletionTheme } from "@/lib/editor/editorThemes"; import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; @@ -199,6 +199,7 @@ let codeMirrorTheme: import("@codemirror/state").Compartment | null = null; let wordWrapComp: import("@codemirror/state").Compartment | null = null; let vimModeComp: import("@codemirror/state").Compartment | null = null; let readOnlyComp: import("@codemirror/state").Compartment | null = null; +let runGutterComp: 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; @@ -231,6 +232,7 @@ let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; let previewRangeComp: import("@codemirror/state").Compartment | null = null; let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null; +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; let semanticDiagnostics: SqlSemanticDiagnostic[] = []; @@ -278,6 +280,7 @@ const queryEditorAppearanceSettings = computed(() => { wordWrap: settings.wordWrap, vimModeEnabled: settings.vimModeEnabled, shortcuts: settings.shortcuts, + showStatementRunButtons: settings.showStatementRunButtons, }; }); @@ -2269,6 +2272,7 @@ onMounted(async () => { wordWrapComp = new Compartment(); vimModeComp = new Compartment(); readOnlyComp = new Compartment(); + runGutterComp = new Compartment(); runKeymapComp = new Compartment(); completionComp = new Compartment(); diagnosticComp = new Compartment(); @@ -2388,17 +2392,23 @@ onMounted(async () => { class RunStatementGutterMarker extends GutterMarker { toDOM() { - const marker = document.createElement("button"); - marker.className = "cm-run-statement-marker cm-run-statement-marker--active"; - marker.setAttribute("type", "button"); - marker.setAttribute("aria-label", "Execute statement"); - marker.innerHTML = - ''; - return marker; + 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 activeLineHighlighter = ViewPlugin.fromClass( class { @@ -2439,15 +2449,7 @@ onMounted(async () => { return { dom }; }, }), - gutter({ - class: "cm-run-statement-gutter", - lineMarker(currentView, line) { - return executableStatementRangeStartingAt(currentView, line.from) ? executableStatementMarker : null; - }, - domEventHandlers: { - mousedown: executeSqlStatementFromGutter, - }, - }), + runGutterComp.of(buildRunStatementGutterExtension()), lineNumbers({ domEventHandlers: { mousedown: selectSqlLineFromGutter, @@ -2816,7 +2818,7 @@ function getCurrentCustomThemeColors() { watch( [queryEditorAppearanceSettings, () => isDark.value, () => themePalette.value], async ([ss]) => { - if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !vimModeComp || !runKeymapComp || !editorViewModule) { + if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !vimModeComp || !runGutterComp || !runKeymapComp || !editorViewModule) { return; } if (!isGestureZooming.value && !zoomCommitScheduler.hasPendingCommit() && liveFontSize.value !== ss.fontSize) { @@ -2825,7 +2827,7 @@ watch( syncEditorFontCssVars(liveFontSize.value, ss.fontFamily); const themeColors = getCurrentCustomThemeColors(); const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]); - if (!view.value || !codeMirrorTheme || !wordWrapComp || !vimModeComp || !runKeymapComp || !editorViewModule) { + if (!view.value || !codeMirrorTheme || !wordWrapComp || !vimModeComp || !runGutterComp || !runKeymapComp || !editorViewModule) { return; } view.value.dispatch({ @@ -2833,6 +2835,7 @@ watch( codeMirrorTheme.reconfigure(themeExt), wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)), + runGutterComp.reconfigure(buildRunStatementGutterExtension?.() ?? []), runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)), ], }); @@ -3075,10 +3078,12 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute }); } :deep(.cm-run-statement-gutter .cm-gutterElement) { + align-items: center; box-sizing: border-box; + display: flex; + justify-content: center; min-width: 34px; padding: 0 5px; - line-height: 24px; } :deep(.cm-run-statement-marker) { @@ -3086,8 +3091,8 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute }); align-items: center; justify-content: center; box-sizing: border-box; - width: 24px; - height: 24px; + width: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6)); + height: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6)); margin: 0; padding: 0; border: 1px solid transparent; @@ -3125,8 +3130,8 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute }); :deep(.cm-run-statement-marker svg) { display: block; - width: 14px; - height: 14px; + width: min(14px, 70%); + height: min(14px, 70%); pointer-events: none; flex-shrink: 0; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9d7492087..30a865341 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2943,6 +2943,9 @@ export default { executeModeCurrent: "Execute statement at cursor", showExecutionTargetPicker: "Show execution target picker", showExecutionTargetPickerDescription: "When enabled, running without a selection lets you choose between the current statement and all SQL.", + showStatementRunButtons: "Show left-side run buttons", + showStatementRunButtonsDescription: "Show per-statement run buttons in the SQL editor gutter. Keyboard shortcuts and context menu execution still work when disabled.", + previewStatementRunButton: "Preview statement run button", wordWrap: "Word wrap", wordWrapDescription: "Wrap long SQL lines within the editor width", vimMode: "Vim mode", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index dd03b4ddf..c85938e36 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2887,6 +2887,9 @@ export default withEnglishFallback({ executeModeCurrent: "Ejecutar sentencia en el cursor", showExecutionTargetPicker: "Mostrar selector de objetivo", showExecutionTargetPickerDescription: "Al activarlo, ejecutar sin selección permite elegir entre la sentencia actual y todo el SQL.", + showStatementRunButtons: "Mostrar botones de ejecución laterales", + showStatementRunButtonsDescription: "Muestra botones para ejecutar cada sentencia en el margen del editor SQL. Los atajos de teclado y el menú contextual seguirán funcionando al desactivarlo.", + previewStatementRunButton: "Botón de ejecución de sentencia en vista previa", wordWrap: "Ajuste de línea", wordWrapDescription: "Ajustar las líneas largas de SQL al ancho del editor", vimMode: "Modo Vim", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3576e64e5..7edad0ae0 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2885,6 +2885,9 @@ export default withEnglishFallback({ executeModeCurrent: "Esegui istruzione al cursore", showExecutionTargetPicker: "Mostra selettore destinazione", showExecutionTargetPickerDescription: "Se attivo, l'esecuzione senza selezione permette di scegliere tra istruzione corrente e tutto l'SQL.", + showStatementRunButtons: "Mostra pulsanti di esecuzione laterali", + showStatementRunButtonsDescription: "Mostra nel margine dell'editor SQL i pulsanti per eseguire ogni istruzione. Scorciatoie da tastiera e menu contestuale continuano a funzionare quando disattivati.", + previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima", wordWrap: "A capo automatico", wordWrapDescription: "Incolonna le righe SQL lunghe entro la larghezza dell'editor", vimMode: "Modalita Vim", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 96ac11d77..9f87ea63f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2876,6 +2876,9 @@ export default withEnglishFallback({ executeModeCurrent: "カーソル位置の文を実行", showExecutionTargetPicker: "実行対象ピッカーを表示", showExecutionTargetPickerDescription: "有効にすると、選択なしで実行するときに現在の文とすべてのSQLを一時的に選べます。", + showStatementRunButtons: "左側の実行ボタンを表示", + showStatementRunButtonsDescription: "SQLエディタのガターに文ごとの実行ボタンを表示します。無効にしてもキーボードショートカットとコンテキストメニューからの実行は引き続き使えます。", + previewStatementRunButton: "文の実行ボタンのプレビュー", wordWrap: "折り返し", wordWrapDescription: "長いSQL行をエディタ幅内で折り返します", vimMode: "Vimモード", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0d30ce604..145702466 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2886,6 +2886,9 @@ export default withEnglishFallback({ executeModeCurrent: "Executar instrução no cursor", showExecutionTargetPicker: "Mostrar seletor de destino", showExecutionTargetPickerDescription: "Quando ativado, executar sem seleção permite escolher entre a instrução atual e todo o SQL.", + showStatementRunButtons: "Mostrar botões de execução laterais", + showStatementRunButtonsDescription: "Mostra botões para executar cada instrução na margem do editor SQL. Atalhos de teclado e execução pelo menu de contexto continuam funcionando quando desativado.", + previewStatementRunButton: "Botão de execução de instrução na prévia", wordWrap: "Quebra de linha", wordWrapDescription: "Quebrar linhas SQL longas dentro da largura do editor", vimMode: "Modo Vim", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3ab6d7584..51fdd652e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2943,6 +2943,9 @@ export default withEnglishFallback({ executeModeCurrent: "执行光标所在语句", showExecutionTargetPicker: "显示执行目标选择器", showExecutionTargetPickerDescription: "开启后,无选区执行时可在当前语句和全部 SQL 之间临时选择。", + showStatementRunButtons: "显示左侧执行按钮", + showStatementRunButtonsDescription: "在 SQL 编辑器左侧显示按语句执行的快捷按钮。关闭后仍可通过快捷键和右键菜单执行。", + previewStatementRunButton: "预览语句执行按钮", wordWrap: "自动换行", wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示", vimMode: "Vim 模式", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 7cc28003f..9754d7ab0 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2783,6 +2783,9 @@ export default withEnglishFallback({ executeModeCurrent: "執行指標所在語句", showExecutionTargetPicker: "顯示執行目標選擇器", showExecutionTargetPickerDescription: "啟用後,無選取執行時可在目前語句與全部 SQL 之間臨時選擇。", + showStatementRunButtons: "顯示左側執行按鈕", + showStatementRunButtonsDescription: "在 SQL 編輯器左側顯示按語句執行的快捷按鈕。關閉後仍可透過快捷鍵和右鍵選單執行。", + previewStatementRunButton: "預覽語句執行按鈕", wordWrap: "自動換行", wordWrapDescription: "長 SQL 在編輯器寬度內自動折行顯示", vimMode: "Vim 模式", diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index 1b7490dea..9f99bcaa3 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -11,6 +11,16 @@ export const EDITOR_FONT_SIZE_CSS_VAR = "--dbx-editor-font-size"; export const EDITOR_FONT_FAMILY_CSS_VAR = "--dbx-editor-font-family"; const EDITOR_SELECTION_BACKGROUND_CSS_VAR = "--dbx-editor-selection-background"; +export function createRunStatementButtonDom(ariaLabel = "Execute statement"): HTMLButtonElement { + const marker = document.createElement("button"); + marker.className = "cm-run-statement-marker cm-run-statement-marker--active"; + marker.setAttribute("type", "button"); + marker.setAttribute("aria-label", ariaLabel); + marker.innerHTML = + ''; + return marker; +} + const SUPPORTS_COLOR_MIX = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "color-mix(in oklch, black 50%, white)"); const SUPPORTS_OKLCH = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", "oklch(0.62 0.19 255)"); @@ -738,6 +748,70 @@ export function buildEditorFontThemeRules(opts?: { fixedHeight?: boolean; scroll paddingRight: "16px", userSelect: "none", }, + ".cm-run-statement-gutter": { + minWidth: "34px", + }, + ".cm-run-statement-gutter .cm-gutterElement": { + alignItems: "center", + boxSizing: "border-box", + display: "flex", + justifyContent: "center", + minWidth: "34px", + padding: "0 5px", + }, + ".cm-run-statement-marker": { + alignItems: "center", + background: "transparent", + border: "1px solid transparent", + borderRadius: "6px", + boxSizing: "border-box", + color: "transparent", + display: "inline-flex", + flexShrink: "0", + height: `min(24px, calc(var(${EDITOR_FONT_SIZE_CSS_VAR}, ${defaults?.size ?? 13}px) * 1.6))`, + justifyContent: "center", + margin: "0", + outline: "none", + padding: "0", + transition: "color 0.15s, background-color 0.15s", + userSelect: "none", + verticalAlign: "middle", + whiteSpace: "nowrap", + width: `min(24px, calc(var(${EDITOR_FONT_SIZE_CSS_VAR}, ${defaults?.size ?? 13}px) * 1.6))`, + }, + ".cm-run-statement-marker--active": { + background: "rgb(16 185 129 / 0.1)", + color: "rgb(4 120 87)", + cursor: "pointer", + }, + ".cm-run-statement-marker--active:hover": { + background: "rgb(16 185 129 / 0.2)", + color: "rgb(6 95 70)", + }, + "&.cm-editor .cm-run-statement-marker svg": { + display: "block", + flexShrink: "0", + height: "min(14px, 70%)", + pointerEvents: "none", + width: "min(14px, 70%)", + }, + "&.cm-editor.cm-focused .cm-run-statement-marker:focus-visible": { + outline: "1px solid var(--ring)", + outlineOffset: "1px", + }, + "&.cm-editor .cm-run-statement-marker--executed": { + background: "rgb(16 185 129 / 0.18)", + color: "rgb(6 95 70)", + }, + "&.cm-editor .cm-settings-preview-run-highlight": { + background: "rgb(16 185 129 / 0.12)", + }, + ".dark &.cm-editor .cm-run-statement-marker--active": { + color: "rgb(110 231 183)", + }, + ".dark &.cm-editor .cm-run-statement-marker--active:hover, .dark &.cm-editor .cm-run-statement-marker--executed": { + color: "rgb(167 243 208)", + }, }; } diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 731d68692..7623a3134 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -370,6 +370,7 @@ export interface EditorSettings { activeCustomThemeId: string; executeMode: "all" | "current"; showExecutionTargetPicker: boolean; + showStatementRunButtons: boolean; autoAliasTables: boolean; wordWrap: boolean; vimModeEnabled: boolean; @@ -494,6 +495,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { activeCustomThemeId: "default", executeMode: "all", showExecutionTargetPicker: false, + showStatementRunButtons: true, autoAliasTables: true, wordWrap: false, vimModeEnabled: false, @@ -715,6 +717,7 @@ export function normalizeEditorSettings(settings: Partial, exist activeCustomThemeId: settings.activeCustomThemeId ?? "default", executeMode: settings.executeMode ?? DEFAULT_EDITOR_SETTINGS.executeMode, showExecutionTargetPicker: settings.showExecutionTargetPicker ?? DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker, + showStatementRunButtons: typeof settings.showStatementRunButtons === "boolean" ? settings.showStatementRunButtons : DEFAULT_EDITOR_SETTINGS.showStatementRunButtons, autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables, wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap, vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled, @@ -948,6 +951,7 @@ export const useSettingsStore = defineStore("settings", () => { } if (partial.executeMode !== undefined) editorSettings.value.executeMode = partial.executeMode; if (partial.showExecutionTargetPicker !== undefined) editorSettings.value.showExecutionTargetPicker = partial.showExecutionTargetPicker; + if (partial.showStatementRunButtons !== undefined) editorSettings.value.showStatementRunButtons = partial.showStatementRunButtons === true; if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables; if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap; if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true; diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 8b818a931..1c22dd169 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -129,6 +129,13 @@ test("defaults dangerous SQL confirmation to enabled", () => { assert.equal(normalizeEditorSettings({ confirmDangerousSqlExecution: false }).confirmDangerousSqlExecution, false); }); +test("defaults statement run buttons to enabled and preserves saved booleans", () => { + assert.equal(DEFAULT_EDITOR_SETTINGS.showStatementRunButtons, true); + assert.equal(normalizeEditorSettings({}).showStatementRunButtons, true); + assert.equal(normalizeEditorSettings({ showStatementRunButtons: false }).showStatementRunButtons, false); + assert.equal(normalizeEditorSettings({ showStatementRunButtons: "nope" as any }).showStatementRunButtons, true); +}); + test("defaults unsaved SQL close confirmation to enabled", () => { assert.equal(DEFAULT_EDITOR_SETTINGS.confirmUnsavedSqlClose, true); assert.equal(normalizeEditorSettings({}).confirmUnsavedSqlClose, true);