feat(editor): add executable statement markers and per-statement run
This commit is contained in:
parent
baeaeba431
commit
df43561738
|
|
@ -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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"></path></svg>';
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue