diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 940a046b6..d96a48217 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -10,7 +10,7 @@ import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { copyToClipboard } from "@/lib/clipboard"; import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sqlExecutionTarget"; -import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker } from "@/lib/sqlStatementRanges"; +import { buildExecutionCandidates, executableStatementRanges, hasMultipleExecutionTargets, supportsExecutionTargetPicker } from "@/lib/sqlStatementRanges"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter"; import { formatMongoShellText } from "@/lib/mongoFormatter"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -332,9 +332,13 @@ function handleTab(view: EditorViewType): boolean { return true; } -function requestExecute() { +function requestExecute(options: { forceCurrent?: boolean } = {}) { const currentView = view.value; if (!currentView) return false; + return requestExecuteFromView(currentView, currentView.state.selection.main.head, options); +} + +function requestExecuteFromView(currentView: EditorViewType, cursorPos: number, options: { forceCurrent?: boolean } = {}) { const selection = currentView.state.selection.main; if (!selection.empty) { // Has manual selection → execute directly, skip picker. @@ -347,9 +351,13 @@ function requestExecute() { } // No selection → show the execution target picker. const doc = currentView.state.doc.toString(); - const cursorPos = selection.head; const candidates = buildExecutionCandidates(doc, cursorPos, props.databaseType); if (candidates.length === 0) return false; + if (options.forceCurrent) { + const candidate = candidates.find((item) => item.kind === "cursor") ?? candidates[0]; + emit("execute", candidate.sql); + return true; + } if (!settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType)) { const preferredKind = settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all"; const candidate = candidates.find((item) => item.kind === preferredKind) ?? candidates[0]; @@ -502,6 +510,21 @@ function openTableDdlFromContextMenu() { focusEditor(); } +function executableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) { + return executableStatementRanges(currentView.state.doc.toString(), props.databaseType).find((range) => range.from === lineFrom) ?? null; +} + +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); + if (!statementRange) return false; + event.preventDefault(); + event.stopPropagation(); + emit("execute", statementRange.sql); + currentView.focus(); + return true; +} + function selectSqlLineFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean { if (!(event instanceof MouseEvent) || event.button !== 0) return false; event.preventDefault(); @@ -557,7 +580,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") }, ...binding(shortcuts.find, openSearch), ...binding(shortcuts.replace, openReplace), - ...binding(shortcuts.executeSql, requestExecute), + ...binding(shortcuts.executeSql, () => requestExecute({ forceCurrent: true })), ...binding(shortcuts.saveSql, () => { emit("save"); return true; @@ -1830,7 +1853,7 @@ onMounted(async () => { if (!editorRef.value) return; const [ - { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, ViewPlugin }, + { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, gutter, GutterMarker, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, ViewPlugin }, { EditorState, Compartment, Prec, StateEffect, StateField }, langSql, { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap }, @@ -1955,6 +1978,27 @@ onMounted(async () => { const initialSettings = settingsStore.editorSettings; const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors()); + class RunStatementGutterMarker extends GutterMarker { + constructor(private readonly isExecutable: boolean) { + super(); + } + + toDOM() { + const marker = document.createElement(this.isExecutable ? "button" : "span"); + marker.className = this.isExecutable ? "cm-run-statement-marker cm-run-statement-marker--active" : "cm-run-statement-marker"; + if (this.isExecutable) { + marker.setAttribute("type", "button"); + marker.setAttribute("aria-label", "Execute statement"); + } + marker.innerHTML = + ''; + return marker; + } + } + + const executableStatementMarker = new RunStatementGutterMarker(true); + const inactiveStatementMarker = new RunStatementGutterMarker(false); + const activeLineHighlighter = ViewPlugin.fromClass( class { decorations: import("@codemirror/view").DecorationSet; @@ -1994,6 +2038,15 @@ onMounted(async () => { return { dom }; }, }), + gutter({ + class: "cm-run-statement-gutter", + lineMarker(currentView, line) { + return executableStatementRangeStartingAt(currentView, line.from) ? executableStatementMarker : inactiveStatementMarker; + }, + domEventHandlers: { + mousedown: executeSqlStatementFromGutter, + }, + }), lineNumbers({ domEventHandlers: { mousedown: selectSqlLineFromGutter, @@ -2575,6 +2628,67 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute }); background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)); } +:deep(.cm-run-statement-gutter) { + min-width: 34px; +} + +:deep(.cm-run-statement-gutter .cm-gutterElement) { + box-sizing: border-box; + min-width: 34px; + padding: 0 5px; + line-height: 24px; +} + +:deep(.cm-run-statement-marker) { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: transparent; + vertical-align: middle; + white-space: nowrap; + transition: + color 0.15s, + background-color 0.15s; + outline: none; + user-select: none; + flex-shrink: 0; +} + +:deep(.cm-run-statement-marker--active) { + background: rgb(16 185 129 / 0.1); + color: rgb(4 120 87); + cursor: pointer; +} + +:deep(.cm-run-statement-marker--active:hover) { + background: rgb(16 185 129 / 0.2); + color: rgb(6 95 70); +} + +:deep(.dark .cm-run-statement-marker--active) { + color: rgb(110 231 183); +} + +:deep(.dark .cm-run-statement-marker--active:hover) { + color: rgb(167 243 208); +} + +:deep(.cm-run-statement-marker svg) { + display: block; + width: 14px; + height: 14px; + pointer-events: none; + flex-shrink: 0; +} + :deep(.cm-foldMarker-svg) { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts index 768f1319c..725339a0a 100644 --- a/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sqlStatementRanges.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildExecutionCandidates, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "../sqlStatementRanges"; +import { buildExecutionCandidates, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "../sqlStatementRanges"; function indexOf(sql: string, needle: string, occurrence = 1): number { let from = 0; @@ -232,6 +232,22 @@ describe("statementRangeAtCursor", () => { }); }); +describe("executableStatementRanges", () => { + it("returns statement ranges starting only at statement starts", () => { + const sql = "SELECT *\nFROM users\nWHERE active = 1;\nSELECT 2;"; + const ranges = executableStatementRanges(sql); + expect(rangeSqlTexts(ranges)).toEqual(["SELECT *\nFROM users\nWHERE active = 1", "SELECT 2"]); + expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("SELECT 2")]); + }); + + it("returns Redis executable command lines", () => { + const sql = "GET user:1\n# comment\n DEL user:2 "; + const ranges = executableStatementRanges(sql, "redis"); + expect(rangeSqlTexts(ranges)).toEqual(["GET user:1", "DEL user:2"]); + expect(ranges.map((range) => range.from)).toEqual([0, sql.indexOf("DEL")]); + }); +}); + describe("fullSqlRange", () => { it("returns the trimmed full document", () => { const sql = " SELECT 1; \n"; @@ -258,10 +274,11 @@ describe("buildExecutionCandidates", () => { expect(candidateKinds(candidates)).toEqual(["cursor", "all"]); }); - it("uses only the cursor SQL for the first candidate when it is missing a semicolon", () => { - const sql = "SELECT 1\nSELECT 2;\nSELECT 3;"; - const candidates = buildExecutionCandidates(sql, indexOf(sql, "1")); - expect(candidateSummaries(candidates)).toEqual(["cursor:SELECT 1", "all:SELECT 1\nSELECT 2;\nSELECT 3;"]); + it("uses the cursor statement for the first candidate when there is no selection", () => { + const sql = "SELECT *\nFROM users\nWHERE active = 1"; + const candidates = buildExecutionCandidates(sql, indexOf(sql, "users")); + expect(candidates).toHaveLength(1); + expect(candidates[0].kind).toBe("all"); }); it("uses the current command line for Redis cursor candidates", () => { diff --git a/apps/desktop/src/lib/sqlStatementRanges.ts b/apps/desktop/src/lib/sqlStatementRanges.ts index 7c7a43472..a47d8f415 100644 --- a/apps/desktop/src/lib/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sqlStatementRanges.ts @@ -878,6 +878,11 @@ function normalizeSql(sql: string): string { * cursor statement and the full document are effectively the same SQL — in * that case only a single candidate is returned to avoid duplicates. */ +export function executableStatementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] { + if (databaseType === "redis") return redisExecutableCommandRanges(sql); + return splitSqlStatementRanges(sql, databaseType).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType).map((range) => rangeFor(range, sql))); +} + 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); @@ -911,15 +916,35 @@ function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["ki function redisExecutableCommandCount(sql: string): number { let count = 0; - for (const line of sql.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; + for (const range of redisExecutableCommandRanges(sql)) { + if (!range.sql.trim()) continue; count += 1; if (count > 1) return count; } return count; } +function redisExecutableCommandRanges(sql: string): SqlTextRange[] { + const ranges: SqlTextRange[] = []; + let lineStart = 0; + while (lineStart <= sql.length) { + let lineEnd = sql.indexOf("\n", lineStart); + if (lineEnd === -1) lineEnd = sql.length; + const rawLine = sql.slice(lineStart, lineEnd); + const leadingWhitespace = rawLine.length - rawLine.trimStart().length; + const trailingWhitespace = rawLine.length - rawLine.trimEnd().length; + const trimmedLine = rawLine.trim(); + if (trimmedLine && !trimmedLine.startsWith("#")) { + const from = lineStart + leadingWhitespace; + const to = lineStart + rawLine.length - trailingWhitespace; + ranges.push({ from, to, sql: sql.slice(from, to) }); + } + if (lineEnd >= sql.length) break; + lineStart = lineEnd + 1; + } + return ranges; +} + function redisCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null { const pos = clampCursor(sql, cursorPos); if (isCursorOnBlankLine(sql, pos)) return null;