feat(editor): add new-result execution toolbar action

Closes #10128
This commit is contained in:
t8y2
2026-09-26 05:56:42 +00:00
parent 7a7edfb2b8
commit 72f32d630f
6 changed files with 140 additions and 5 deletions
+11 -3
View File
@@ -943,9 +943,16 @@ function requestActiveEditorExecute(source?: "pointer" | "keyboard", tabId?: str
void tryExecute(undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
function requestActiveEditorExecuteInNewResultTab() {
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.()) return;
void tryExecuteInNewResultTab();
function requestActiveEditorExecuteInNewResultTab(source?: "pointer" | "keyboard", tabId?: string) {
const snapshot = pendingToolbarExecutionSnapshot.value;
pendingToolbarExecutionSnapshot.value = undefined;
const targetTabId = tabId ?? (source === "pointer" ? snapshot?.tabId : undefined);
if (source === "pointer" && snapshot && snapshot.tabId === targetTabId) {
void tryExecuteInNewResultTab(snapshot, { tabId: targetTabId });
return;
}
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.(targetTabId)) return;
void tryExecuteInNewResultTab(undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
const toolbarAgentDriverUpdateCount = computed(() => Math.max(agentDriverUpdateCount.value, componentUpdates.driverUpdateCount.value));
@@ -989,6 +996,7 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
databaseRequiredSignalFor: (tabId: string) => (databaseRequiredTabId.value === tabId ? databaseRequiredSignal.value : 0),
captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,
toolbarExecute: requestActiveEditorExecute,
toolbarExecuteInNewResultTab: requestActiveEditorExecuteInNewResultTab,
cancelExecution: (tabId: string) => cancelActiveExecution(tabId),
explain: (tabId: string) => tryExplain(undefined, { tabId }),
formatSql: formatActiveSql,
@@ -208,6 +208,7 @@ const groupExecutableSql = computed(() => {
@dismiss-auto-commit-session-txn-rolled-back="activeTab && (activeTab.autoCommitSessionTxnRolledBack = false)"
@execute-pointer-down="toolbar.captureExecutionSnapshot(activeTab.id)"
@toolbar-execute="toolbar.toolbarExecute($event, activeTab.id)"
@toolbar-execute-in-new-result-tab="toolbar.toolbarExecuteInNewResultTab($event, activeTab.id)"
@multi-execute="toolbar.multiExecute()"
@preview-changes="activeTab && toolbar.previewChanges(activeTab.id)"
@cancel="activeTab && toolbar.cancelExecution(activeTab.id)"
@@ -1,7 +1,35 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText, RefreshCw, UserRound } from "@lucide/vue";
import {
Play,
SquarePlay,
CirclePlay,
Loader2,
Square,
Database,
Check,
Table2,
AlignLeft,
GitBranch,
Save,
FolderOpen,
X,
Shield,
Download,
RotateCcw,
AlertTriangle,
ClipboardPaste,
Minimize2,
SpellCheck2,
Layers,
MoreHorizontal,
BetweenVerticalStart,
Eye,
WrapText,
RefreshCw,
UserRound,
} from "@lucide/vue";
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
@@ -63,6 +91,7 @@ const props = defineProps<{
const emit = defineEmits<{
toolbarExecute: [source: "pointer" | "keyboard"];
toolbarExecuteInNewResultTab: [source: "pointer" | "keyboard"];
executePointerDown: [];
cancel: [];
previewChanges: [];
@@ -289,6 +318,11 @@ function refreshObjectSource() {
}
const executeShortcutDisplay = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.executeSql));
const executeShortcutTooltip = computed(() => t("toolbar.executeShortcut", { shortcut: executeShortcutDisplay.value }));
const executeInNewResultTabShortcutDisplay = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.executeSqlInNewResultTab));
const executeInNewResultTabTooltip = computed(() => {
const label = t("settings.shortcutExecuteSqlInNewResultTab");
return executeInNewResultTabShortcutDisplay.value ? `${label} (${executeInNewResultTabShortcutDisplay.value})` : label;
});
// executableSql 在无选区时可能是整篇文档;只要有 DML 语句出现就显示预览按钮,
// 具体"当前语句"由编辑器(QueryEditor)按执行模式解析。
const DML_KEYWORD_RE = /(^|\s)(update|insert|delete)\s/i;
@@ -469,6 +503,10 @@ function onExecuteClick(event: MouseEvent) {
emit("toolbarExecute", event.detail > 0 ? "pointer" : "keyboard");
}
function onExecuteInNewResultTabClick(event: MouseEvent) {
emit("toolbarExecuteInNewResultTab", event.detail > 0 ? "pointer" : "keyboard");
}
async function changeCatalog(selectedCatalog: string) {
const connection = props.activeConnection;
if (!connection) return;
@@ -504,6 +542,22 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExecuting ? t("toolbar.stopQuery") : executeShortcutTooltip }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-violet-600 hover:bg-violet-500/10 hover:text-violet-700 dark:text-violet-300 dark:hover:text-violet-200"
:disabled="activeTab.isExecuting || activeTab.isCancelling || activeTab.isExplaining || !executableSql.trim()"
:aria-label="t('settings.shortcutExecuteSqlInNewResultTab')"
@mousedown.prevent="onExecutePointerDown"
@click="onExecuteInNewResultTabClick"
>
<SquarePlay class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ executeInNewResultTabTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="showPreviewButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700" :disabled="activeTab.isExecuting || activeTab.isCancelling" :aria-label="t('editor.previewChanges')" @mousedown.prevent @click="emit('previewChanges')">
@@ -200,6 +200,7 @@ describe("EditorGroup mount contract", () => {
...createNoopEditorToolbarActions(),
captureExecutionSnapshot: vi.fn(),
toolbarExecute: vi.fn(),
toolbarExecuteInNewResultTab: vi.fn(),
cancelExecution: vi.fn(),
explain: vi.fn(),
saveSql: vi.fn(),
@@ -233,12 +234,14 @@ describe("EditorGroup mount contract", () => {
// Toolbar events flow to the injected actions with the group's tab id.
(toolbar as any).__vueParentComponent.emit("executePointerDown");
(toolbar as any).__vueParentComponent.emit("toolbarExecute", "pointer");
(toolbar as any).__vueParentComponent.emit("toolbarExecuteInNewResultTab", "pointer");
(toolbar as any).__vueParentComponent.emit("cancel");
(toolbar as any).__vueParentComponent.emit("explain");
(toolbar as any).__vueParentComponent.emit("saveSql", queryId);
await nextTick();
expect(actions.captureExecutionSnapshot).toHaveBeenCalledWith(queryId);
expect(actions.toolbarExecute).toHaveBeenCalledWith("pointer", queryId);
expect(actions.toolbarExecuteInNewResultTab).toHaveBeenCalledWith("pointer", queryId);
expect(actions.cancelExecution).toHaveBeenCalledWith(queryId);
expect(actions.explain).toHaveBeenCalledWith(queryId);
expect(actions.saveSql).toHaveBeenCalledWith(queryId);
@@ -220,7 +220,7 @@ describe("EditorToolbar mount contract", () => {
host.remove();
});
it("executes the exact CodeMirror selection after the toolbar click moves focus", async () => {
it("keeps the exact CodeMirror selection for regular and new-result toolbar execution", async () => {
const connectionStore = useConnectionStore();
const connection = {
id: "conn-1",
@@ -237,6 +237,7 @@ describe("EditorToolbar mount contract", () => {
const state = reactive({ sql: fullSql });
let pendingSnapshot: SqlExecutionSnapshot | undefined;
let executedSql = "";
let executedInNewResultSql = "";
const host = createHost();
const app = createApp({
@@ -283,6 +284,9 @@ describe("EditorToolbar mount contract", () => {
onToolbarExecute: (source: "pointer" | "keyboard") => {
executedSql = source === "pointer" && pendingSnapshot ? resolveExecutableSql(pendingSnapshot.fullSql, pendingSnapshot.selectedSql, { mode: "current", cursorPos: pendingSnapshot.cursorPos }) : fullSql;
},
onToolbarExecuteInNewResultTab: (source: "pointer" | "keyboard") => {
executedInNewResultSql = source === "pointer" && pendingSnapshot ? resolveExecutableSql(pendingSnapshot.fullSql, pendingSnapshot.selectedSql, { mode: "current", cursorPos: pendingSnapshot.cursorPos }) : fullSql;
},
}),
]);
},
@@ -304,6 +308,69 @@ describe("EditorToolbar mount contract", () => {
expect(pendingSnapshot?.selectedSql).toBe(selectedSql);
expect(executedSql).toBe(selectedSql);
const newResultButton = host.querySelector<HTMLButtonElement>('button[aria-label="settings.shortcutExecuteSqlInNewResultTab"]');
expect(newResultButton).not.toBeNull();
expect(newResultButton?.disabled).toBe(false);
newResultButton?.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }));
newResultButton?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
await nextTick();
expect(pendingSnapshot?.selectedSql).toBe(selectedSql);
expect(executedInNewResultSql).toBe(selectedSql);
app.unmount();
host.remove();
});
it("disables new-result execution when there is no executable SQL", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [
{
id: "conn-empty",
name: "empty",
db_type: "mysql",
color: "",
} as never,
];
const host = createHost();
const onToolbarExecuteInNewResultTab = vi.fn();
const app = createApp(EditorToolbar, {
activeTab: {
id: "tab-empty",
title: "SQL",
connectionId: "conn-empty",
database: "db",
sql: "",
mode: "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
},
activeConnection: connectionStore.getConfig("conn-empty"),
executableSql: "",
explainMode: "explain",
blockDangerousRedisCommands: false,
sqlKeywordCase: "preserve",
databaseRequiredSignal: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: false,
txnPossiblyDirty: false,
stickyProvenReadOnlyState: false,
onToolbarExecuteInNewResultTab,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const newResultButton = host.querySelector<HTMLButtonElement>('button[aria-label="settings.shortcutExecuteSqlInNewResultTab"]');
expect(newResultButton).not.toBeNull();
expect(newResultButton?.disabled).toBe(true);
newResultButton?.click();
expect(onToolbarExecuteInNewResultTab).not.toHaveBeenCalled();
app.unmount();
host.remove();
});
@@ -28,6 +28,7 @@ export interface EditorToolbarActions {
/** Captures the acting editor's execution snapshot before a toolbar click. */
captureExecutionSnapshot(tabId: string): void;
toolbarExecute(source: "pointer" | "keyboard", tabId: string): void;
toolbarExecuteInNewResultTab(source: "pointer" | "keyboard", tabId: string): void;
cancelExecution(tabId: string): void;
explain(tabId: string): void;
formatSql(tabId: string): void;
@@ -75,6 +76,7 @@ export function createNoopEditorToolbarActions(): EditorToolbarActions {
databaseRequiredSignalFor: () => 0,
captureExecutionSnapshot: noop,
toolbarExecute: noop,
toolbarExecuteInNewResultTab: noop,
cancelExecution: noop,
explain: noop,
formatSql: noop,