diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 169c67884..44eeaac3a 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -73,7 +73,9 @@ import { eventToShortcut } from "@/lib/editor/keyboardShortcuts"; 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, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; +import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; 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"; @@ -238,6 +240,7 @@ const showThemeCustomizer = ref(false); const editExecuteMode = ref(settingsStore.editorSettings.executeMode); const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker); const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStatementRunButtons); +const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame); const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); @@ -548,6 +551,7 @@ watch( editExecuteMode.value = settingsStore.editorSettings.executeMode; editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker; editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons; + editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame; editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editWordWrap.value = settingsStore.editorSettings.wordWrap; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; @@ -641,6 +645,7 @@ function hasChanges(): boolean { editExecuteMode.value !== settingsStore.editorSettings.executeMode || editShowExecutionTargetPicker.value !== settingsStore.editorSettings.showExecutionTargetPicker || editShowStatementRunButtons.value !== settingsStore.editorSettings.showStatementRunButtons || + editShowCurrentStatementFrame.value !== settingsStore.editorSettings.showCurrentStatementFrame || editAutoAliasTables.value !== settingsStore.editorSettings.autoAliasTables || editWordWrap.value !== settingsStore.editorSettings.wordWrap || editVimModeEnabled.value !== settingsStore.editorSettings.vimModeEnabled || @@ -701,6 +706,7 @@ async function persistSettings() { executeMode: editExecuteMode.value, showExecutionTargetPicker: editShowExecutionTargetPicker.value, showStatementRunButtons: editShowStatementRunButtons.value, + showCurrentStatementFrame: editShowCurrentStatementFrame.value, autoAliasTables: editAutoAliasTables.value, wordWrap: editWordWrap.value, vimModeEnabled: editVimModeEnabled.value, @@ -784,6 +790,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons; + editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -854,6 +861,7 @@ function resetAllDefaults() { editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker; editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons; + editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; @@ -2005,6 +2013,7 @@ const previewSettings = computed<{ appPalette: AppThemePalette; customColors?: CustomThemeColors; showStatementRunButtons: boolean; + showCurrentStatementFrame: boolean; }>(() => ({ fontFamily: editFontFamily.value, fontSize: editFontSize.value, @@ -2013,6 +2022,7 @@ const previewSettings = computed<{ appPalette: themePalette.value, customColors: getPreviewCustomThemeColors(), showStatementRunButtons: editShowStatementRunButtons.value, + showCurrentStatementFrame: editShowCurrentStatementFrame.value, })); const previewSqlNormal = `SELECT u.id, u.name @@ -2034,6 +2044,7 @@ 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 currentStatementFrameComp: 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; @@ -2111,6 +2122,79 @@ function handlePreviewRunGutterMouseDown(currentView: EditorViewType, line: { fr return true; } +function buildPreviewCurrentStatementFrameExtension(viewModule: Pick, enabled: boolean) { + if (!enabled) return []; + const { Decoration, EditorView, ViewPlugin } = 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)", + 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)", + }, + }); + 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 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, visualSqlColumns(view.state.doc.sliceString(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]; +} + +function previewCurrentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number { + const nextChar = range.to < view.state.doc.length ? view.state.doc.sliceString(range.to, range.to + 1) : ""; + return currentStatementFrameRangeTo(nextChar, range); +} + watch( [previewSettings, editCustomThemes, editActiveCustomThemeId], async ([ss]) => { @@ -2118,7 +2202,12 @@ 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)), ...(previewRunGutterComp ? [previewRunGutterComp.reconfigure(buildPreviewRunGutterExtension())] : [])], + effects: [ + themeComp.reconfigure(themeExt), + fontThemeComp.reconfigure(editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily)), + ...(previewRunGutterComp ? [previewRunGutterComp.reconfigure(buildPreviewRunGutterExtension())] : []), + ...(currentStatementFrameComp ? [currentStatementFrameComp.reconfigure(buildPreviewCurrentStatementFrameExtension(editorViewModule, ss.showCurrentStatementFrame))] : []), + ], }); }, { deep: true }, @@ -2139,6 +2228,7 @@ function cleanupPreviewEditor() { themeComp = null; diagnosticComp = null; previewRunGutterComp = null; + currentStatementFrameComp = null; setPreviewDiagnosticsEffect = null; setPreviewRunHighlightEffect = null; editorViewModule = null; @@ -2163,13 +2253,14 @@ watch(previewRef, async (el) => { previewInitialized = true; if (previewView.value) return; - 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")]); + 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")]); - editorViewModule = { EditorView } as typeof import("@codemirror/view"); + editorViewModule = { Decoration, EditorView, ViewPlugin } as typeof import("@codemirror/view"); fontThemeComp = new Compartment(); themeComp = new Compartment(); diagnosticComp = new Compartment(); previewRunGutterComp = new Compartment(); + currentStatementFrameComp = new Compartment(); setPreviewDiagnosticsEffect = StateEffect.define(); setPreviewRunHighlightEffect = StateEffect.define<{ from: number; to: number } | null>(); previewSqlDiagnostics = previewDiagnosticsForSql(currentPreviewSql()); @@ -2249,6 +2340,7 @@ watch(previewRef, async (el) => { themeComp.of(themeExt), fontThemeComp.of(editorFontTheme(EditorView, ss.fontSize, ss.fontFamily)), previewRunGutterComp.of(buildPreviewRunGutterExtension()), + currentStatementFrameComp.of(buildPreviewCurrentStatementFrameExtension(editorViewModule, ss.showCurrentStatementFrame)), diagnosticComp.of(buildPreviewDiagnosticExtension()), buildPreviewRunHighlightExtension(), ], @@ -2394,6 +2486,14 @@ onUnmounted(cleanupPreviewEditor); +
+
+ +

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

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 0fc260ef8..a072d711e 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -11,7 +11,8 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC import { copyToClipboard } from "@/lib/common/clipboard"; import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget"; import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; -import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; +import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; +import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore"; @@ -282,6 +283,7 @@ const queryEditorAppearanceSettings = computed(() => { activeCustomThemeId: settings.activeCustomThemeId, wordWrap: settings.wordWrap, vimModeEnabled: settings.vimModeEnabled, + showCurrentStatementFrame: settings.showCurrentStatementFrame, shortcuts: settings.shortcuts, showStatementRunButtons: settings.showStatementRunButtons, }; @@ -758,6 +760,12 @@ function executableStatementRangeStartingAt(currentView: EditorViewType, lineFro return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom); } +function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null { + if (!supportsExecutionTargetPicker(props.databaseType)) return null; + executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType); + return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head); +} + function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean { if (!(event instanceof MouseEvent) || event.button !== 0) return false; const statementRange = executableStatementRangeStartingAt(currentView, line.from); @@ -2551,6 +2559,51 @@ onMounted(async () => { }) : []; + const currentStatementFrameHighlighter = 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 (!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 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, visualSqlColumns(view.state.doc.sliceString(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 }, + ); + + function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number { + const nextChar = range.to < view.state.doc.length ? view.state.doc.sliceString(range.to, range.to + 1) : ""; + return currentStatementFrameRangeTo(nextChar, range); + } + const activeLineHighlighter = ViewPlugin.fromClass( class { decorations: import("@codemirror/view").DecorationSet; @@ -2596,6 +2649,7 @@ onMounted(async () => { mousedown: selectSqlLineFromGutter, }, }), + currentStatementFrameHighlighter, highlightActiveLineGutter(), highlightSpecialChars(), history(), @@ -2941,6 +2995,14 @@ watch( }, ); +watch( + () => props.databaseType, + () => { + executableStatementRangeCache = null; + view.value?.dispatch({}); + }, +); + watch( () => props.forceWordWrap, () => { @@ -3219,6 +3281,31 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute }); background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)); } +: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); + 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-run-statement-gutter) { min-width: 34px; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 86630dc1c..8b056140a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2958,6 +2958,8 @@ export default { 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.", + showCurrentStatementFrame: "Show current statement frame", + showCurrentStatementFrameDescription: "When enabled, the SQL editor draws an outline around the current executable statement; when disabled, the outline is hidden.", previewStatementRunButton: "Preview statement run button", wordWrap: "Word wrap", wordWrapDescription: "Wrap long SQL lines within the editor width", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0e94386ad..5191ccc0f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2889,6 +2889,8 @@ export default withEnglishFallback({ 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.", + showCurrentStatementFrame: "Mostrar marco de la sentencia actual", + showCurrentStatementFrameDescription: "Al activarlo, el editor SQL dibuja un contorno alrededor de la sentencia ejecutable actual; al desactivarlo, se oculta.", 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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9979e671f..af1e3ea05 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2887,6 +2887,8 @@ export default withEnglishFallback({ 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.", + showCurrentStatementFrame: "Mostra cornice istruzione corrente", + showCurrentStatementFrameDescription: "Se attivo, l'editor SQL disegna un contorno intorno all'istruzione eseguibile corrente; se disattivato, il contorno è nascosto.", previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima", wordWrap: "A capo automatico", wordWrapDescription: "Incolonna le righe SQL lunghe entro la larghezza dell'editor", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 12318a554..888622fc7 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2878,6 +2878,8 @@ export default withEnglishFallback({ showExecutionTargetPickerDescription: "有効にすると、選択なしで実行するときに現在の文とすべてのSQLを一時的に選べます。", showStatementRunButtons: "左側の実行ボタンを表示", showStatementRunButtonsDescription: "SQLエディタのガターに文ごとの実行ボタンを表示します。無効にしてもキーボードショートカットとコンテキストメニューからの実行は引き続き使えます。", + showCurrentStatementFrame: "現在の文の枠線を表示", + showCurrentStatementFrameDescription: "有効にすると、SQLエディタで現在実行可能な文を枠線で示します。無効にすると枠線を表示しません。", previewStatementRunButton: "文の実行ボタンのプレビュー", wordWrap: "折り返し", wordWrapDescription: "長いSQL行をエディタ幅内で折り返します", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b20843a4f..6a784786b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2888,6 +2888,8 @@ export default withEnglishFallback({ 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.", + showCurrentStatementFrame: "Mostrar moldura da instrução atual", + showCurrentStatementFrameDescription: "Quando ativado, o editor SQL desenha um contorno ao redor da instrução executável atual; quando desativado, o contorno fica oculto.", 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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 381423f9c..f8c9f5ec6 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2958,6 +2958,8 @@ export default withEnglishFallback({ showExecutionTargetPickerDescription: "开启后,无选区执行时可在当前语句和全部 SQL 之间临时选择。", showStatementRunButtons: "显示左侧执行按钮", showStatementRunButtonsDescription: "在 SQL 编辑器左侧显示按语句执行的快捷按钮。关闭后仍可通过快捷键和右键菜单执行。", + showCurrentStatementFrame: "显示当前语句外框线", + showCurrentStatementFrameDescription: "开启后,在 SQL 编辑器中用外框线标出当前可执行语句;关闭后不显示外框线。", previewStatementRunButton: "预览语句执行按钮", wordWrap: "自动换行", wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3e78035d3..cd846a97d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2785,6 +2785,8 @@ export default withEnglishFallback({ showExecutionTargetPickerDescription: "啟用後,無選取執行時可在目前語句與全部 SQL 之間臨時選擇。", showStatementRunButtons: "顯示左側執行按鈕", showStatementRunButtonsDescription: "在 SQL 編輯器左側顯示按語句執行的快捷按鈕。關閉後仍可透過快捷鍵和右鍵選單執行。", + showCurrentStatementFrame: "顯示目前語句外框線", + showCurrentStatementFrameDescription: "啟用後,在 SQL 編輯器中用外框線標出目前可執行語句;關閉後不顯示外框線。", previewStatementRunButton: "預覽語句執行按鈕", wordWrap: "自動換行", wordWrapDescription: "長 SQL 在編輯器寬度內自動折行顯示", diff --git a/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts new file mode 100644 index 000000000..9b282102d --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/currentStatementFrame.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { currentStatementFrameRangeTo, isWideSqlChar, visualSqlColumns } from "@/lib/sql/currentStatementFrame"; +import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges"; + +describe("currentStatementFrameRangeTo", () => { + it("includes a directly adjacent trailing semicolon in frame width calculations", () => { + const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" }; + expect(currentStatementFrameRangeTo(";", range)).toBe(range.to + 1); + }); + + it("does not extend the frame when the next character is not a semicolon", () => { + const range: SqlTextRange = { from: 0, to: "SELECT 1".length, sql: "SELECT 1" }; + expect(currentStatementFrameRangeTo("\n", range)).toBe(range.to); + }); +}); + +describe("visualSqlColumns", () => { + it("counts ASCII as one column, tabs as four, and CJK/fullwidth characters as two", () => { + expect(visualSqlColumns("A\t中B")).toBe(1 + 4 + 2 + 2); + }); + + it("recognizes common wide SQL text characters", () => { + expect(isWideSqlChar("中")).toBe(true); + expect(isWideSqlChar("A")).toBe(true); + expect(isWideSqlChar("A")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index c55be0d0d..bd6ae1a3c 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -1,6 +1,6 @@ import { Text } from "@codemirror/state"; import { describe, expect, it, vi } from "vitest"; -import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/sql/executableStatementRangeCache"; +import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/sql/executableStatementRangeCache"; describe("executableStatementRangeCacheForDoc", () => { it("reuses parsed executable statement ranges for the same document and database type", () => { @@ -27,6 +27,41 @@ describe("executableStatementRangeCacheForDoc", () => { expect(executableStatementRangeStartingAt(cache, secondStatementLine.from)?.sql).toBe("SELECT *\nFROM menus AS mn\nLIMIT 100"); }); + it("resolves the current statement from a cursor inside a continuation line", () => { + const doc = Text.of(["SELECT *", "FROM apis AS ap", "LIMIT 100;", "", "SELECT *", "FROM menus AS mn", "LIMIT 100;"]); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + const cursor = doc.toString().indexOf("menus"); + + expect(executableStatementRangeAtCursor(cache, cursor)?.sql).toBe("SELECT *\nFROM menus AS mn\nLIMIT 100"); + }); + + it("keeps indentation and same-line semicolon gaps attached to the current statement", () => { + const doc = Text.of(["SELECT 1;", " SELECT 2;"]); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + const indentationCursor = doc.line(2).from + 2; + const semicolonGapCursor = doc.toString().indexOf(";") + 1; + + expect(executableStatementRangeAtCursor(cache, indentationCursor)?.sql).toBe("SELECT 2"); + expect(executableStatementRangeAtCursor(cache, semicolonGapCursor)?.sql).toBe("SELECT 1"); + }); + + it("returns null for blank and pure comment cursor lines", () => { + const doc = Text.of(["SELECT 1;", "-- comment", "/* block comment */", "", "SELECT 2;"]); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + + expect(executableStatementRangeAtCursor(cache, doc.line(2).from + 3)).toBeNull(); + expect(executableStatementRangeAtCursor(cache, doc.line(3).from + 3)).toBeNull(); + expect(executableStatementRangeAtCursor(cache, doc.line(4).from)).toBeNull(); + }); + + it("resolves SQL after a leading block comment on the same line", () => { + const doc = Text.of(["/* comment */ SELECT 1;"]); + const cache = executableStatementRangeCacheForDoc(null, doc, "mysql"); + + expect(executableStatementRangeAtCursor(cache, doc.toString().indexOf("SELECT"))?.sql).toBe("SELECT 1"); + expect(executableStatementRangeAtCursor(cache, doc.toString().indexOf("comment"))).toBeNull(); + }); + it("rebuilds the cache when the document instance changes", () => { const firstDoc = Text.of(["SELECT 1;"]); const secondDoc = Text.of(["SELECT 1;"]); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 7229cc917..efe949b0d 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildExecutionCandidates, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges"; +import { buildExecutionCandidates, currentExecutableStatementRange, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges"; function indexOf(sql: string, needle: string, occurrence = 1): number { let from = 0; @@ -74,6 +74,17 @@ BEGIN END; SELECT 2;`; +const mysqlRoutineWithLoopsFixture = `CREATE PROCEDURE p_loop() +BEGIN + WHILE 1 = 0 DO + SELECT 'while; still body'; + END WHILE; + REPEAT + SELECT 'repeat; still body'; + UNTIL 1 = 1 END REPEAT; +END; +SELECT 2;`; + describe("splitSqlStatementRanges", () => { it("splits multiple top-level statements", () => { const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;"; @@ -149,6 +160,16 @@ describe("splitSqlStatementRanges", () => { expect(rangeSqlTexts(splitSqlStatementRanges(sql, "mysql"))).toEqual(["BEGIN", "INSERT INTO t VALUES (1)", "COMMIT"]); }); + it("treats SQL Server GO lines as batch delimiters", () => { + const sql = "SELECT 1\nGO\nSELECT 2;\n GO 2\nSELECT 3"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "sqlserver"))).toEqual(["SELECT 1", "SELECT 2", "SELECT 3"]); + }); + + it("does not treat GO inside strings or comments as a SQL Server batch delimiter", () => { + const sql = "SELECT 'GO'\n-- GO\nSELECT 2\nGO\nSELECT 3"; + expect(rangeSqlTexts(splitSqlStatementRanges(sql, "sqlserver"))).toEqual(["SELECT 'GO'\n-- GO\nSELECT 2", "SELECT 3"]); + }); + it("keeps Oracle PL/SQL blocks together and treats slash lines as delimiters", () => { const ranges = splitSqlStatementRanges(oraclePlSqlFixture, "oracle"); expect(rangeSqlTexts(ranges)).toEqual([oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/")), "SELECT 1"]); @@ -386,6 +407,18 @@ WHERE request_json LIKE '%"paperFlag":null%';`; expect(range?.sql.trim()).toBe(mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim()); }); + it("returns null on SQL Server GO batch delimiter lines", () => { + const sql = "SELECT 1\nGO\nSELECT 2"; + expect(statementRangeAtCursor(sql, indexOf(sql, "GO"), "sqlserver")).toBeNull(); + }); + + it("returns the current SQL Server batch around GO delimiters", () => { + const sql = "SELECT 1\nGO\nSELECT 2\nGO\nSELECT 3"; + expect(statementRangeAtCursor(sql, indexOf(sql, "1"), "sqlserver")?.sql.trim()).toBe("SELECT 1"); + expect(statementRangeAtCursor(sql, indexOf(sql, "2"), "sqlserver")?.sql.trim()).toBe("SELECT 2"); + expect(statementRangeAtCursor(sql, indexOf(sql, "3"), "sqlserver")?.sql.trim()).toBe("SELECT 3"); + }); + it("returns the full Oracle PL/SQL block for cursors inside nested statements", () => { const range = statementRangeAtCursor(oraclePlSqlFixture, indexOf(oraclePlSqlFixture, "ORDERS_10K", 2), "oracle"); expect(range?.sql.trim()).toBe(oraclePlSqlFixture.slice(0, oraclePlSqlFixture.indexOf("\n/"))); @@ -427,6 +460,42 @@ describe("executableStatementRanges", () => { it("does not split executable MySQL routine ranges at inner statements", () => { expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineFixture, "mysql"))).toEqual([mysqlRoutineFixture.slice(0, mysqlRoutineFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); }); + + it("does not split executable MySQL routine ranges at WHILE and REPEAT endings", () => { + expect(rangeSqlTexts(executableStatementRanges(mysqlRoutineWithLoopsFixture, "mysql"))).toEqual([mysqlRoutineWithLoopsFixture.slice(0, mysqlRoutineWithLoopsFixture.indexOf("\nSELECT 2;")).replace(/;$/, "").trim(), "SELECT 2"]); + }); + + it("returns executable SQL Server batches without GO delimiter lines", () => { + expect(rangeSqlTexts(executableStatementRanges("SELECT 1\nGO\nSELECT 2;", "sqlserver"))).toEqual(["SELECT 1", "SELECT 2"]); + }); +}); + +describe("currentExecutableStatementRange", () => { + it("uses the current SQL statement range for multi-line DDL", () => { + const sql = "ALTER TABLE `yb_course_order`\n ADD COLUMN `audit_status` tinyint(4) DEFAULT NULL\n COMMENT '审核状态:0-待审核,1-已通过,2-已拒绝',\n ADD COLUMN `close_reason` varchar(30) DEFAULT NULL\n COMMENT '关闭原因:timeout-超时关闭,cancel-取消关闭,refund-退款关闭';\nSELECT 1;"; + + expect(currentExecutableStatementRange(sql, indexOf(sql, "close_reason"), "mysql")?.sql.trim()).toBe(sql.slice(0, sql.indexOf(";\nSELECT"))); + }); + + it("returns null on blank and pure comment lines", () => { + const sql = "SELECT 1;\n-- comment\n\nSELECT 2;"; + + expect(currentExecutableStatementRange(sql, indexOf(sql, "comment"), "mysql")).toBeNull(); + expect(currentExecutableStatementRange(sql, sql.indexOf("\n\n") + 1, "mysql")).toBeNull(); + }); + + it("uses the current Redis command line", () => { + const sql = "GET user:1\n DEL user:2\n# comment"; + + expect(currentExecutableStatementRange(sql, indexOf(sql, "DEL"), "redis")?.sql).toBe("DEL user:2"); + expect(currentExecutableStatementRange(sql, indexOf(sql, "comment"), "redis")).toBeNull(); + }); + + it("does not expose current statement framing for MongoDB", () => { + const sql = "db.users.find({})"; + + expect(currentExecutableStatementRange(sql, indexOf(sql, "users"), "mongodb")).toBeNull(); + }); }); describe("fullSqlRange", () => { @@ -526,6 +595,12 @@ describe("buildExecutionCandidates", () => { const candidates = buildExecutionCandidates(sql, indexOf(sql, "COUNT", 2), "mysql"); expect(candidateSummaries(candidates)).toEqual(["cursor:select COUNT(1) FROM your_table;", "all:select COUNT(1) FROM your_table;\ndelimiter ;;\nselect COUNT(1) FROM your_table;\n\n;;\ndelimiter ;"]); }); + + it("uses the current SQL Server batch for cursor candidates", () => { + const sql = "SELECT 1\nGO\nSELECT 2;"; + const candidates = buildExecutionCandidates(sql, indexOf(sql, "2"), "sqlserver"); + expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 2", "all:SELECT 1\nGO\nSELECT 2;"]); + }); }); describe("hasMultipleExecutionTargets", () => { @@ -555,6 +630,10 @@ describe("hasMultipleExecutionTargets", () => { expect(hasMultipleExecutionTargets(mysqlRoutineFixture, "mysql")).toBe(true); }); + it("counts SQL Server GO batches as multiple execution targets", () => { + expect(hasMultipleExecutionTargets("SELECT 1\nGO\nSELECT 2", "sqlserver")).toBe(true); + }); + it("does not show multiple targets for MySQL DESC UPDATE joins", () => { const sql = "desc update test_orders a\njoin test_users b\non a.id=b.id \nset a.name = '张三'\nwhere b.id > 10;"; expect(hasMultipleExecutionTargets(sql, "mysql")).toBe(false); diff --git a/apps/desktop/src/lib/sql/currentStatementFrame.ts b/apps/desktop/src/lib/sql/currentStatementFrame.ts new file mode 100644 index 000000000..1b99e3873 --- /dev/null +++ b/apps/desktop/src/lib/sql/currentStatementFrame.ts @@ -0,0 +1,23 @@ +import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges"; + +export function currentStatementFrameRangeTo(nextChar: string, range: SqlTextRange): number { + return nextChar === ";" ? range.to + 1 : range.to; +} + +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); +} diff --git a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts index bbeee1209..356428903 100644 --- a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts +++ b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts @@ -6,6 +6,7 @@ export interface ExecutableStatementRangeCache { doc: Text; databaseType?: DatabaseType; byStart: Map; + ranges: SqlTextRange[]; } export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType) => SqlTextRange[]; @@ -14,12 +15,59 @@ export function executableStatementRangeCacheForDoc(cache: ExecutableStatementRa if (cache?.doc === doc && cache.databaseType === databaseType) return cache; const byStart = new Map(); - for (const range of parse(doc.toString(), databaseType)) { + const ranges = parse(doc.toString(), databaseType); + for (const range of ranges) { byStart.set(range.from, range); } - return { doc, databaseType, byStart }; + return { doc, databaseType, byStart, ranges }; } export function executableStatementRangeStartingAt(cache: ExecutableStatementRangeCache, lineFrom: number): SqlTextRange | null { return cache.byStart.get(lineFrom) ?? null; } + +export function executableStatementRangeAtCursor(cache: ExecutableStatementRangeCache, cursorPos: number): SqlTextRange | null { + const pos = Math.max(0, Math.min(cursorPos, cache.doc.length)); + const line = cache.doc.lineAt(pos); + const lineText = line.text.trim(); + if (!lineText || lineText.startsWith("--") || lineText.startsWith("#") || isCursorOnLeadingBlockComment(line.text, pos - line.from)) return null; + + for (let index = 0; index < cache.ranges.length; index += 1) { + const range = cache.ranges[index]; + if (pos >= range.from && pos <= range.to) return range; + + if (pos < range.from && range.from <= line.to && cache.doc.sliceString(pos, range.from).trim() === "") { + return range; + } + + const next = cache.ranges[index + 1]; + if (pos > range.to && (!next || pos < next.from) && range.to >= line.from && range.to <= line.to && cursorRemainsOnRangeLine(cache.doc, range.to, pos)) { + return range; + } + } + + return null; +} + +function isCursorOnLeadingBlockComment(lineText: string, lineOffset: number): boolean { + const commentStart = lineText.search(/\S/); + if (commentStart < 0 || !lineText.startsWith("/*", commentStart)) return false; + + const commentEnd = lineText.indexOf("*/", commentStart + 2); + if (commentEnd < 0) return true; + const afterComment = lineText.slice(commentEnd + 2); + if (!afterComment.trim()) return true; + + return lineOffset <= commentEnd + 2; +} + +function cursorRemainsOnRangeLine(doc: Text, rangeTo: number, cursorPos: number): boolean { + const between = doc.sliceString(rangeTo, cursorPos); + if (between.includes("\n")) return false; + const delimiterIndex = between.lastIndexOf(";"); + if (delimiterIndex === -1) return between.trim() === ""; + + const beforeDelimiter = between.slice(0, delimiterIndex); + const afterDelimiter = between.slice(delimiterIndex + 1); + return beforeDelimiter.trim() === "" && afterDelimiter.trim() === ""; +} diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 0be1579a8..155a542b2 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -271,6 +271,14 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } + if (supportsSqlServerGoCommands(databaseType) && isAtLineStart(sql, i) && isSqlServerGoLine(sql, i)) { + const lineEnd = findLineEnd(sql, i); + flush(i); + i = nextLineStart(sql, lineEnd); + statementHitStart = i; + continue; + } + if (ch === "'") { markContent(i); state = "single"; @@ -296,7 +304,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType continue; } // Postgres dollar quoting: $tag$ ... $tag$ (tag may be empty, i.e. $$) - if (ch === "$") { + if (!customDelimiter && ch === "$") { const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i)); if (tagMatch) { markContent(i); @@ -1386,6 +1394,15 @@ function isSlashLine(sql: string, pos: number): boolean { return sql.slice(pos, lineEnd).trim() === "/"; } +function supportsSqlServerGoCommands(databaseType?: DatabaseType): boolean { + return databaseType === "sqlserver"; +} + +function isSqlServerGoLine(sql: string, pos: number): boolean { + const lineEnd = findLineEnd(sql, pos); + return /^go(?:\s+\d+)?$/i.test(sql.slice(pos, lineEnd).trim()); +} + function startsDelimiterCommand(sql: string, pos: number): boolean { const prefix = sql.slice(pos, pos + 9); return prefix.toLowerCase() === "delimiter" && (sql[pos + 9] === " " || sql[pos + 9] === "\t"); @@ -1468,9 +1485,15 @@ export function executableStatementRanges(sql: string, databaseType?: DatabaseTy return splitSqlStatementRanges(sql, databaseType).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType).map((range) => rangeFor(range, sql))); } +export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null { + if (databaseType === "redis") return redisCommandRangeAtCursor(sql, cursorPos); + if (databaseType === "mongodb") return null; + return statementRangeAtCursor(sql, cursorPos, databaseType); +} + export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlExecutionCandidate[] { const full = fullSqlRange(sql); - const cursorStatement = databaseType === "redis" ? redisCommandRangeAtCursor(sql, cursorPos) : statementRangeAtCursor(sql, cursorPos, databaseType); + const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType); if (!full && !cursorStatement) return []; if (!full) { diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 14fd20bfb..aa2e61032 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -10,6 +10,14 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false); }); + it("shows the current statement frame by default", () => { + expect(normalizeEditorSettings({}).showCurrentStatementFrame).toBe(true); + }); + + it("preserves disabled current statement frames", () => { + expect(normalizeEditorSettings({ showCurrentStatementFrame: false }).showCurrentStatementFrame).toBe(false); + }); + it("keeps SQL semantic diagnostics in auto mode and disabled by default", () => { const settings = normalizeEditorSettings({}); expect(settings.sqlSemanticDiagnosticsMode).toBe("auto"); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 7623a3134..689b2e53f 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -371,6 +371,7 @@ export interface EditorSettings { executeMode: "all" | "current"; showExecutionTargetPicker: boolean; showStatementRunButtons: boolean; + showCurrentStatementFrame: boolean; autoAliasTables: boolean; wordWrap: boolean; vimModeEnabled: boolean; @@ -496,6 +497,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { executeMode: "all", showExecutionTargetPicker: false, showStatementRunButtons: true, + showCurrentStatementFrame: true, autoAliasTables: true, wordWrap: false, vimModeEnabled: false, @@ -718,6 +720,7 @@ export function normalizeEditorSettings(settings: Partial, exist 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, + showCurrentStatementFrame: typeof settings.showCurrentStatementFrame === "boolean" ? settings.showCurrentStatementFrame : DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame, 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, @@ -952,6 +955,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.showCurrentStatementFrame !== undefined) editorSettings.value.showCurrentStatementFrame = partial.showCurrentStatementFrame === 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/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index 950801584..900542d76 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -2778,6 +2778,28 @@ SELECT 2;"; ); } + #[test] + fn mysql_routine_without_delimiter_handles_loop_end_suffixes() { + let sql = "\ +CREATE PROCEDURE p_loop() +BEGIN + WHILE 1 = 0 DO + SELECT 'while; still body'; + END WHILE; + REPEAT + SELECT 'repeat; still body'; + UNTIL 1 = 1 END REPEAT; +END; +SELECT 2;"; + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec![ + "CREATE PROCEDURE p_loop()\nBEGIN\n WHILE 1 = 0 DO\n SELECT 'while; still body';\n END WHILE;\n REPEAT\n SELECT 'repeat; still body';\n UNTIL 1 = 1 END REPEAT;\nEND", + "SELECT 2", + ] + ); + } + #[test] fn mysql_regular_begin_transaction_still_splits_without_delimiter() { assert_eq!(