feat(editor): retain multiple query result runs

This commit is contained in:
zhangyc 2026-07-27 15:06:28 +08:00 committed by GitHub
parent fddc0fdf50
commit 7f20b03177
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 846 additions and 109 deletions

View File

@ -57,6 +57,7 @@ import {
isBrowserReloadShortcut,
isCloseOtherTabsShortcut,
isCloseTabShortcut,
isExecuteSqlInNewResultTabShortcut,
isExecuteSqlShortcut,
isFocusSearchShortcut,
isModRShortcut,
@ -300,6 +301,7 @@ const {
showDangerDialog,
suppressDangerConfirm,
tryExecute,
tryExecuteInNewResultTab,
doExecute,
cancelActiveExecution,
tryExplain,
@ -326,6 +328,11 @@ function requestActiveEditorExecute() {
void tryExecute();
}
function requestActiveEditorExecuteInNewResultTab() {
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.()) return;
void tryExecuteInNewResultTab();
}
const dialogs = useDialogSources();
const { getDatabaseOptions } = useDatabaseOptions();
const { openLineageTarget, openDatabaseSearchTarget, openDiagramTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs);
@ -1904,6 +1911,12 @@ function handleKeydown(e: KeyboardEvent) {
void openSaveSqlDialog();
return;
}
if (activeTab.value?.mode === "query" && isExecuteSqlInNewResultTabShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
e.preventDefault();
e.stopPropagation();
requestActiveEditorExecuteInNewResultTab();
return;
}
if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
e.preventDefault();
e.stopPropagation();
@ -2265,6 +2278,7 @@ onUnmounted(() => {
@fix-with-ai="fixWithAi"
@send-selection-to-ai="sendSelectionToAi"
@execute="tryExecute($event)"
@execute-in-new-result-tab="tryExecuteInNewResultTab($event)"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"

View File

@ -134,6 +134,7 @@ const emit = defineEmits<{
cursorChange: [pos: number];
formatError: [message: string];
execute: [source: SqlExecutionOverride];
executeInNewResultTab: [source: SqlExecutionOverride];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [target: SqlObjectNavigationTarget];
@ -562,6 +563,15 @@ function performNormalTab(view: EditorViewType): boolean {
interface RequestExecuteOptions {
ignoreSelection?: boolean;
bypassPicker?: boolean;
openInNewResultTab?: boolean;
}
function emitExecutionRequest(source: SqlExecutionOverride, openInNewResultTab = false) {
if (openInNewResultTab) {
emit("executeInNewResultTab", source);
} else {
emit("execute", source);
}
}
function requestExecute(options: RequestExecuteOptions = {}) {
@ -570,15 +580,19 @@ function requestExecute(options: RequestExecuteOptions = {}) {
return requestExecuteFromView(currentView, currentView.state.selection.main.head, options);
}
function requestExecuteInNewResultTab() {
return requestExecute({ bypassPicker: true, openInNewResultTab: true });
}
function requestExecuteFromView(currentView: EditorViewType, cursorPos: number, options: RequestExecuteOptions = {}) {
const selection = currentView.state.selection.main;
if (!options.ignoreSelection && !selection.empty) {
// Has manual selection execute directly, skip picker.
emit("execute", sqlExecutionSnapshotFromView(currentView));
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
if (!supportsExecutionTargetPicker(props.databaseType)) {
emit("execute", sqlExecutionSnapshotFromView(currentView));
emitExecutionRequest(sqlExecutionSnapshotFromView(currentView), options.openInNewResultTab);
return true;
}
// No selection resolve the execution target, optionally via the picker.
@ -590,7 +604,7 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number,
if (options.bypassPicker || !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];
emit("execute", candidate.sql);
emitExecutionRequest(candidate.sql, options.openInNewResultTab);
return true;
}
closePicker();
@ -963,6 +977,12 @@ function executeFromContextMenu() {
focusEditor();
}
function executeInNewResultTabFromContextMenu() {
if (!canExecuteContextSql.value) return;
requestExecuteInNewResultTab();
focusEditor();
}
async function copySelectedSqlFromContextMenu() {
if (!canCopySelectedSql.value) return;
try {
@ -1230,6 +1250,13 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
icon: Play,
shortcut: shortcuts.executeSql,
},
{
label: t("settings.shortcutExecuteSqlInNewResultTab"),
action: executeInNewResultTabFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
shortcut: shortcuts.executeSqlInNewResultTab,
},
]),
...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem),
{ label: "", separator: true },
@ -1299,6 +1326,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
// Keep the shortcut on the shared execution-mode path (selection priority + configured cursor/all target),
// but bypass the picker so the keyboard shortcut always executes directly instead of popping a dialog.
const executeBindings = props.hideExecutionControls ? [] : binding(shortcuts.executeSql, () => requestExecute({ bypassPicker: true }));
const executeInNewResultTabBindings = props.hideExecutionControls ? [] : binding(shortcuts.executeSqlInNewResultTab, requestExecuteInNewResultTab);
return [
Prec?.high(
codeMirrorKeymap.of([
@ -1309,6 +1337,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
},
...binding(shortcuts.find, openSearch),
...binding(shortcuts.replace, openReplace),
...executeInNewResultTabBindings,
...executeBindings,
...binding(shortcuts.saveSql, () => {
emit("save");
@ -4358,6 +4387,7 @@ defineExpose({
openReplace,
scrollCursorIntoView,
requestExecute,
requestExecuteInNewResultTab,
pasteClipboardAsSqlInCondition,
previewStatementRange,
refreshCompletionCache,

View File

@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert } from "@lucide/vue";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, PanelsTopLeft } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -65,7 +65,7 @@ const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explai
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
@ -143,6 +143,7 @@ const emit = defineEmits<{
fixWithAi: [errorMessage: string];
sendSelectionToAi: [sql: string];
execute: [sqlOverride?: SqlExecutionOverride];
executeInNewResultTab: [sqlOverride?: SqlExecutionOverride];
saveSql: [];
cancel: [];
explain: [];
@ -210,6 +211,7 @@ const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
const resultRunDisplayMode = computed(() => settingsStore.editorSettings.resultRunDisplayMode);
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
@ -250,6 +252,10 @@ function setDataGridSearchMode(value: DataGridSearchMode) {
settingsStore.updateEditorSettings({ dataGridSearchMode: value });
}
function setResultRunDisplayMode(value: ResultRunDisplayMode) {
settingsStore.updateEditorSettings({ resultRunDisplayMode: value });
}
function setColumnWidthDensity(value: "compact" | "standard" | "comfortable") {
settingsStore.updateEditorSettings({ columnWidthDensity: value });
}
@ -323,7 +329,15 @@ const activeQueryError = computed(() => {
});
const hasQueryOutput = computed(
() =>
!!props.activeTab.result || props.activeTab.resultEvicted === true || !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true,
!!props.activeTab.result ||
!!props.activeTab.resultRuns?.length ||
props.activeTab.resultEvicted === true ||
!!props.activeTab.explainPlan ||
!!props.activeTab.explainError ||
!!props.activeTab.explainTableResult ||
!!props.activeTab.explainTableError ||
props.activeTab.isExecuting === true ||
props.activeTab.isExplaining === true,
);
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined)));
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
@ -335,7 +349,6 @@ const allResultExportSheets = computed(() =>
})),
);
const resultRuns = computed(() => resultRunItems(props.activeTab));
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
@ -352,11 +365,16 @@ const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponse
const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
const showResultRunSelector = computed(() => resultAutoSave.value && resultRuns.value.length > 0);
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs");
const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list");
watch(
() => visibleResultItems.value.map((item) => item.index).join(","),
() => `${resultRunDisplayMode.value}:${resultRuns.value.map((run) => run.id).join(",")}:${props.activeTab.activeResultRunId ?? ""}`,
() => {
nextTick(updateResultTabsScrollbar);
nextTick(() => {
updateResultTabsScrollbar();
resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
},
);
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
@ -758,15 +776,44 @@ async function exportResultArchive() {
async function removeResultRun(runId: string) {
const removedActiveRun = props.activeTab.activeResultRunId === runId;
const removed = await queryStore.removeResultRun(props.activeTab.id, runId);
if (removed && removedActiveRun) emit("update:activeOutputView", "result");
if (!removed) return;
if (removedActiveRun) emit("update:activeOutputView", "result");
await nextTick();
const activeRunTab = resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]');
activeRunTab?.focus({ preventScroll: true });
activeRunTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
async function selectResultRun(runId: string) {
if (!(await queryStore.setActiveResultRun(props.activeTab.id, runId))) {
toast(t("tabs.missingResultRun"), 4000);
return;
return false;
}
emit("update:activeOutputView", "result");
return true;
}
async function focusResultRunByIndex(index: number) {
const run = resultRuns.value[index];
if (!run) return;
if (!(await selectResultRun(run.id))) return;
await nextTick();
const runTabs = resultTabsScrollerRef.value?.querySelectorAll<HTMLElement>("[data-result-run-tab]");
const runTab = runTabs?.[index];
runTab?.focus({ preventScroll: true });
runTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
}
function onResultRunTabKeydown(event: KeyboardEvent, currentIndex: number) {
const lastIndex = resultRuns.value.length - 1;
let targetIndex: number | undefined;
if (event.key === "ArrowLeft") targetIndex = currentIndex > 0 ? currentIndex - 1 : lastIndex;
if (event.key === "ArrowRight") targetIndex = currentIndex < lastIndex ? currentIndex + 1 : 0;
if (event.key === "Home") targetIndex = 0;
if (event.key === "End") targetIndex = lastIndex;
if (targetIndex === undefined || targetIndex < 0) return;
event.preventDefault();
void focusResultRunByIndex(targetIndex);
}
function toggleResultAutoSave() {
@ -794,6 +841,10 @@ function requestQueryEditorExecute() {
return queryEditorRef.value?.requestExecute();
}
function requestQueryEditorExecuteInNewResultTab() {
return queryEditorRef.value?.requestExecuteInNewResultTab();
}
function pasteClipboardAsSqlInCondition() {
return queryEditorRef.value?.pasteClipboardAsSqlInCondition();
}
@ -812,7 +863,7 @@ async function executeRedisCommand(command: string): Promise<boolean> {
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, requestQueryEditorExecuteInNewResultTab, pasteClipboardAsSqlInCondition, applyTableStructureChanges, insertRedisCommand, executeRedisCommand });
</script>
<template>
@ -860,6 +911,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
@format-error="emit('formatError')"
@execute="emit('execute', $event)"
@execute-in-new-result-tab="emit('executeInNewResultTab', $event)"
@save="emit('saveSql')"
@click-table="onHandleClickTable"
@view-table-data="onHandleViewTableData"
@ -892,47 +944,82 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
>
<Pin class="h-3.5 w-3.5" :class="{ 'fill-current': resultAutoSave }" />
</Button>
<template v-if="showResultRunSelector">
<template v-if="resultRuns.length > 0 || visibleResultItems.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs">
{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<template v-if="visibleResultItems.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<div class="relative min-w-0 flex-1 self-stretch">
<div v-if="showResultRunTabs" data-result-run-tabs-region class="relative min-w-0 flex-1 self-stretch">
<div v-if="hasResultTabOverflow" class="result-tab-scrollbar" :class="{ 'result-tab-scrollbar--dragging': isResultTabsScrollbarDragging }" @pointerdown="startResultTabsScrollbarDrag">
<div class="result-tab-scrollbar__thumb" :style="resultTabsScrollbarThumbStyle" />
</div>
<div ref="resultTabsScrollerRef" class="result-tab-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :style="resultTabsScrollerStyle" @scroll="updateResultTabsScrollbar" @wheel="onResultTabsWheel">
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
<div role="tablist" :aria-label="t('tabs.resultRuns')" class="flex h-full shrink-0 items-center gap-1">
<div
v-for="(run, runIndex) in resultRuns"
:key="run.id"
role="presentation"
class="group/result-run inline-flex h-7 shrink-0 items-center overflow-hidden rounded-md border transition-colors"
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
>
<button
type="button"
role="tab"
data-result-run-tab
:tabindex="run.active ? 0 : -1"
:aria-selected="run.active"
:data-active-result-run="run.active ? 'true' : undefined"
class="h-full whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
@click="selectResultRun(run.id)"
@keydown="onResultRunTabKeydown($event, runIndex)"
>
{{ t("tabs.runN", { n: run.sequence }) }}
</button>
<button
type="button"
class="mr-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</div>
</div>
</div>
<div v-else-if="showResultRunSelector" class="min-w-0 flex-1">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span class="min-w-0 truncate">{{ activeResultRunItem ? t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem v-for="run in resultRuns" :key="run.id" class="flex items-center gap-2 pr-1" @select="selectResultRun(run.id)">
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ t("tabs.runN", { n: run.sequence }) }}</span>
<button
type="button"
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('tabs.removeRun', { n: run.sequence })"
:aria-label="t('tabs.removeRun', { n: run.sequence })"
@click.stop.prevent="removeResultRun(run.id)"
>
<X class="h-3 w-3" />
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
<div v-if="visibleResultItems.length > 0" data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 items-center gap-1" :class="resultRuns.length > 0 ? 'shrink-0' : 'flex-1 overflow-x-auto'">
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse">
@ -945,6 +1032,30 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<PanelsTopLeft class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.resultRunDisplayMode") }}</span>
</div>
<div role="group" :aria-label="t('grid.resultRunDisplayMode')" class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="resultRunDisplayMode === 'list' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setResultRunDisplayMode('list')"
>
{{ t("grid.resultRunDisplayList") }}
</button>
<button
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="resultRunDisplayMode === 'tabs' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setResultRunDisplayMode('tabs')"
>
{{ t("grid.resultRunDisplayTabs") }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<SquareDashed class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

View File

@ -147,6 +147,81 @@ describe("useSqlExecution", () => {
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { sourceOffset: selectionFrom });
});
it("forwards execute-in-new-result-tab intent to the query store", async () => {
const sql = "SELECT * FROM users";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecuteInNewResultTab();
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
});
it("does not record or refresh when a new-result execution restores the prior run", async () => {
const result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), result });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockResolvedValue(false);
const addHistory = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const refreshObjects = vi.spyOn(useConnectionStore(), "refreshObjectListTreeNode").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "CREATE TABLE users (id INT)"),
activeOutputView,
});
await execution.tryExecuteInNewResultTab();
expect(addHistory).not.toHaveBeenCalled();
expect(refreshObjects).not.toHaveBeenCalled();
});
it("keeps the new-result-tab intent through SQL parameter input", async () => {
const sql = "SELECT * FROM users WHERE id = :id";
const resolvedSql = "SELECT * FROM users WHERE id = 7";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["id"], rows: [[7]], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecuteInNewResultTab();
expect(execution.showSqlParameterDialog.value).toBe(true);
expect(executeCurrentSql).not.toHaveBeenCalled();
await execution.onSqlParametersConfirm(resolvedSql);
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true });
});
it("sends native SET variables without client-side expansion", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
@ -325,7 +400,7 @@ describe("useSqlExecution", () => {
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "relation does not exist" }));
});
it("keeps the full dangerous script pending and executes it unchanged after confirmation", async () => {
it("keeps the full dangerous script and new-result-tab intent through confirmation", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
@ -343,7 +418,7 @@ describe("useSqlExecution", () => {
activeOutputView,
});
await execution.tryExecute();
await execution.tryExecuteInNewResultTab();
expect(execution.showDangerDialog.value).toBe(true);
expect(execution.pendingDangerSql.value).toBe(sql);
@ -351,7 +426,67 @@ describe("useSqlExecution", () => {
await execution.onDangerConfirm();
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
});
it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => {
const result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
const activeTab = ref<QueryTab | undefined>({
...queryTab("app"),
result,
resultRuns: [{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1", createdAt: 1, result }],
activeResultRunId: "run-1",
});
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql");
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "DROP TABLE users"),
activeOutputView,
});
await execution.tryExecuteInNewResultTab();
expect(execution.showDangerDialog.value).toBe(true);
execution.showDangerDialog.value = false;
await Promise.resolve();
expect(executeCurrentSql).not.toHaveBeenCalled();
expect(activeTab.value?.activeResultRunId).toBe("run-1");
expect(activeTab.value?.result).toEqual(result);
});
it("keeps the new-result-tab intent through Redis command confirmation", async () => {
const sql = "DEL user:1";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("0"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("redis"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
blockDangerousRedisCommands: ref(true),
});
await execution.tryExecuteInNewResultTab();
expect(execution.showDangerDialog.value).toBe(true);
expect(executeCurrentSql).not.toHaveBeenCalled();
await execution.onDangerConfirm();
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { skipRedisSafetyCheck: false, openInNewResultTab: true });
});
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {

View File

@ -22,6 +22,10 @@ import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database"
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
interface SqlExecutionOptions {
openInNewResultTab?: boolean;
}
export function stripSqlComments(sql: string): string {
return sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
@ -98,6 +102,7 @@ export function useSqlExecution(deps: {
const pendingSourceOffset = ref<number | undefined>();
const pendingDangerKind = ref<"sql" | "redis">("sql");
const pendingDangerSourceOffset = ref<number | undefined>();
const pendingOpenInNewResultTab = ref(false);
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number }> {
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type).atSet;
@ -112,7 +117,7 @@ export function useSqlExecution(deps: {
return { sql, sourceOffset: source.selectionFrom + leadingWhitespace };
}
async function tryExecute(sqlOverride?: SqlExecutionOverride) {
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
const tab = deps.activeTab.value;
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride);
if (!tab || !sql.trim()) return;
@ -120,11 +125,15 @@ export function useSqlExecution(deps: {
deps.onMissingDatabase?.();
return;
}
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset)) return;
await continueExecute(sql, sourceOffset);
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, options)) return;
await continueExecute(sql, sourceOffset, options);
}
async function continueExecute(sql: string, sourceOffset?: number) {
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride) {
return tryExecute(sqlOverride, { openInNewResultTab: true });
}
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
const commands = sql
@ -151,6 +160,7 @@ export function useSqlExecution(deps: {
pendingDangerSql.value = sql;
pendingDangerKind.value = "redis";
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
return;
@ -166,7 +176,7 @@ export function useSqlExecution(deps: {
productionDatabases: productionAssessment.databases,
source: t("production.sourceSqlEditor"),
});
if (confirmed) await doExecute(sql, sourceOffset);
if (confirmed) await doExecute(sql, sourceOffset, options);
return;
}
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
@ -174,14 +184,15 @@ export function useSqlExecution(deps: {
pendingDangerSql.value = sql;
pendingDangerKind.value = "sql";
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
} else {
await doExecute(sql, sourceOffset);
await doExecute(sql, sourceOffset, options);
}
}
function prepareSqlParameterDialog(sql: string, sourceOffset?: number): boolean {
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}): boolean {
const databaseType = deps.activeConnection.value?.db_type;
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType);
const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles);
@ -192,11 +203,12 @@ export function useSqlExecution(deps: {
sqlParameterDatabaseType.value = databaseType;
sqlParameterEnabledSyntaxes.value = enabledSyntaxes;
pendingSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
showSqlParameterDialog.value = true;
return true;
}
async function doExecute(sql?: string, sourceOffset?: number) {
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (sql === undefined) ({ sql, sourceOffset } = await resolvedExecutableSql());
const tab = deps.activeTab.value;
if (!tab || !sql.trim()) return;
@ -210,10 +222,12 @@ export function useSqlExecution(deps: {
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = executionDatabaseType === "redis";
await queryStore.executeCurrentSql(sql, {
const producedResult = await queryStore.executeCurrentSql(sql, {
...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
});
if (producedResult === false) return;
if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
deps.activeOutputView.value = "summary";
}
@ -278,17 +292,20 @@ export function useSqlExecution(deps: {
const sql = pendingDangerSql.value;
const sourceOffset = pendingDangerSourceOffset.value;
const kind = pendingDangerKind.value;
const openInNewResultTab = pendingOpenInNewResultTab.value;
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
if (suppressDangerConfirm.value && kind === "sql") {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
await doExecute(sql, sourceOffset);
await doExecute(sql, sourceOffset, { openInNewResultTab });
}
async function onSqlParametersConfirm(sql: string) {
const openInNewResultTab = pendingOpenInNewResultTab.value;
showSqlParameterDialog.value = false;
sqlParameterSourceSql.value = "";
sqlParameterNames.value = [];
@ -296,7 +313,8 @@ export function useSqlExecution(deps: {
sqlParameterEnabledSyntaxes.value = [];
const sourceOffset = pendingSourceOffset.value;
pendingSourceOffset.value = undefined;
await continueExecute(sql, sourceOffset);
pendingOpenInNewResultTab.value = false;
await continueExecute(sql, sourceOffset, { openInNewResultTab });
}
watch(showSqlParameterDialog, (open) => {
@ -306,6 +324,7 @@ export function useSqlExecution(deps: {
sqlParameterDatabaseType.value = undefined;
sqlParameterEnabledSyntaxes.value = [];
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
});
watch(showDangerDialog, (open) => {
@ -313,6 +332,7 @@ export function useSqlExecution(deps: {
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
suppressDangerConfirm.value = false;
});
@ -322,6 +342,7 @@ export function useSqlExecution(deps: {
showDangerDialog,
suppressDangerConfirm,
tryExecute,
tryExecuteInNewResultTab,
doExecute,
cancelActiveExecution,
tryExplain,

View File

@ -855,6 +855,7 @@ export default {
resultN: "Result {n}",
runN: "Run {n}",
resultRuns: "Result runs",
resultSets: "Result sets",
removeRun: "Remove run {n}",
autoKeepResults: "Auto-keep query results",
autoKeepResultsEnabled: "Auto-keep results enabled",
@ -1109,6 +1110,9 @@ export default {
resetColumnOrder: "Reset order",
showAllColumns: "Show all",
viewOptions: "View options",
resultRunDisplayMode: "Result tabs",
resultRunDisplayList: "List",
resultRunDisplayTabs: "Tiled",
hideNullColumns: "Hide NULL",
hideNullColumnsHint: "Toggle columns whose values are all NULL in the current result.",
searchMode: "Search mode",
@ -4259,6 +4263,7 @@ export default {
queryExportKeysetOptimizationEnabledDescription: "Only applies to safely recognized single-table queries; complex queries automatically fall back.",
exportSection: "Export",
shortcutExecuteSql: "Execute SQL",
shortcutExecuteSqlInNewResultTab: "Execute SQL in new result tab",
shortcutFind: "Find",
shortcutReplace: "Replace",
shortcutSaveSql: "Save SQL",

View File

@ -834,6 +834,7 @@ export default withEnglishFallback({
resultN: "Resultado {n}",
runN: "Ejecutar {n}",
resultRuns: "Ejecuciones de resultado",
resultSets: "Conjuntos de resultados",
removeRun: "Eliminar ejecución {n}",
autoKeepResults: "Conservar resultados automáticamente",
autoKeepResultsEnabled: "Conservación automática activada",
@ -1055,6 +1056,9 @@ export default withEnglishFallback({
resetColumnOrder: "Restablecer orden",
showAllColumns: "Mostrar todo",
viewOptions: "Opciones de vista",
resultRunDisplayMode: "Pestañas de resultados",
resultRunDisplayList: "Lista",
resultRunDisplayTabs: "Pestañas",
hideNullColumns: "Ocultar NULL",
hideNullColumnsHint: "Alterna columnas cuyos valores son todos NULL en el resultado actual.",
moreValues: "{count} valores más, sigue escribiendo para acotar los resultados",
@ -4014,6 +4018,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabledDescription: "Solo aplica a consultas de una sola tabla reconocidas de forma segura; las consultas complejas usan el método estándar automáticamente.",
exportSection: "Exportar",
shortcutExecuteSql: "Ejecutar SQL",
shortcutExecuteSqlInNewResultTab: "Ejecutar SQL en una nueva pestaña de resultados",
shortcutFind: "Buscar",
shortcutReplace: "Reemplazar",
shortcutSaveSql: "Guardar SQL",

View File

@ -832,6 +832,7 @@ export default withEnglishFallback({
resultN: "Risultato {n}",
runN: "Esegui {n}",
resultRuns: "Esecuzioni risultati",
resultSets: "Set di risultati",
removeRun: "Rimuovi esecuzione {n}",
autoKeepResults: "Mantieni automaticamente i risultati",
autoKeepResultsEnabled: "Mantieni risultati abilitato",
@ -1053,6 +1054,9 @@ export default withEnglishFallback({
resetColumnOrder: "Reimposta ordine",
showAllColumns: "Mostra tutte",
viewOptions: "Opzioni visualizzazione",
resultRunDisplayMode: "Schede risultati",
resultRunDisplayList: "Elenco",
resultRunDisplayTabs: "Schede",
hideNullColumns: "Nascondi NULL",
hideNullColumnsHint: "Nascondi le colonne i cui valori sono tutti NULL nel risultato corrente.",
moreValues: "altri {count} valori, continua a digitare per restringere i risultati",
@ -4012,6 +4016,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabledDescription: "Si applica solo a query a tabella singola riconosciute in sicurezza; le query complesse ricadono automaticamente.",
exportSection: "Esportazione",
shortcutExecuteSql: "Esegui SQL",
shortcutExecuteSqlInNewResultTab: "Esegui SQL in una nuova scheda dei risultati",
shortcutFind: "Trova",
shortcutReplace: "Sostituisci",
shortcutSaveSql: "Salva SQL",

View File

@ -831,6 +831,7 @@ export default withEnglishFallback({
resultN: "結果 {n}",
runN: "実行 {n}",
resultRuns: "実行履歴",
resultSets: "結果セット",
removeRun: "実行 {n} を削除",
autoKeepResults: "クエリ結果を自動保持",
autoKeepResultsEnabled: "結果の自動保持をオンにしました",
@ -1050,6 +1051,9 @@ export default withEnglishFallback({
resetColumnOrder: "順序をリセット",
showAllColumns: "すべて表示",
viewOptions: "表示オプション",
resultRunDisplayMode: "実行結果",
resultRunDisplayList: "リスト",
resultRunDisplayTabs: "タブ",
hideNullColumns: "NULL列を非表示",
hideNullColumnsHint: "現在の結果ですべてNULLの列の表示を切り替えます。",
moreValues: "さらに{count}件の値があります。絞り込むには入力を続けてください",
@ -3996,6 +4000,7 @@ export default withEnglishFallback({
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数100〜100000。",
exportSection: "エクスポート",
shortcutExecuteSql: "SQLを実行",
shortcutExecuteSqlInNewResultTab: "新しい結果タブでSQLを実行",
shortcutFind: "検索",
shortcutReplace: "置換",
shortcutSaveSql: "SQLを保存",

View File

@ -834,6 +834,7 @@ export default withEnglishFallback({
resultN: "Resultado {n}",
runN: "Executar {n}",
resultRuns: "Execuções de resultado",
resultSets: "Conjuntos de resultados",
removeRun: "Remover execução {n}",
autoKeepResults: "Manter resultados automaticamente",
autoKeepResultsEnabled: "Manutenção automática de resultados ativada",
@ -1055,6 +1056,9 @@ export default withEnglishFallback({
resetColumnOrder: "Redefinir ordem",
showAllColumns: "Mostrar todas",
viewOptions: "Opções de visualização",
resultRunDisplayMode: "Abas de resultados",
resultRunDisplayList: "Lista",
resultRunDisplayTabs: "Abas",
hideNullColumns: "Ocultar NULL",
hideNullColumnsHint: "Alterna colunas cujos valores são todos NULL no resultado atual.",
moreValues: "Mais {count} valores, continue digitando para refinar os resultados",
@ -4014,6 +4018,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabledDescription: "Aplica-se apenas a consultas de tabela única reconhecidas com segurança; consultas complexas recuam automaticamente.",
exportSection: "Exportar",
shortcutExecuteSql: "Executar SQL",
shortcutExecuteSqlInNewResultTab: "Executar SQL em uma nova aba de resultados",
shortcutFind: "Localizar",
shortcutReplace: "Substituir",
shortcutSaveSql: "Salvar SQL",

View File

@ -856,6 +856,7 @@ export default withEnglishFallback({
resultN: "结果 {n}",
runN: "执行 {n}",
resultRuns: "执行结果",
resultSets: "语句结果",
removeRun: "删除执行 {n}",
autoKeepResults: "自动保留查询结果",
autoKeepResultsEnabled: "已开启自动保留结果",
@ -1110,6 +1111,9 @@ export default withEnglishFallback({
resetColumnOrder: "重置顺序",
showAllColumns: "显示全部",
viewOptions: "视图选项",
resultRunDisplayMode: "结果标签",
resultRunDisplayList: "列表",
resultRunDisplayTabs: "平铺",
hideNullColumns: "隐藏 NULL 列",
hideNullColumnsHint: "切换隐藏当前结果中整列均为 NULL 的字段。",
searchMode: "搜索模式",
@ -4258,6 +4262,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。",
exportSection: "导出",
shortcutExecuteSql: "执行 SQL",
shortcutExecuteSqlInNewResultTab: "在新结果标签页中执行 SQL",
shortcutFind: "查找",
shortcutReplace: "替换",
shortcutSaveSql: "保存 SQL",

View File

@ -833,6 +833,7 @@ export default withEnglishFallback({
resultN: "結果 {n}",
runN: "執行 {n}",
resultRuns: "執行結果",
resultSets: "語句結果",
removeRun: "移除執行 {n}",
autoKeepResults: "自動保留查詢結果",
autoKeepResultsEnabled: "自動保留結果已啟用",
@ -1054,6 +1055,9 @@ export default withEnglishFallback({
resetColumnOrder: "重設順序",
showAllColumns: "顯示全部",
viewOptions: "檢視選項",
resultRunDisplayMode: "結果標籤",
resultRunDisplayList: "清單",
resultRunDisplayTabs: "平鋪",
hideNullColumns: "隱藏 NULL 欄",
hideNullColumnsHint: "切換隱藏目前結果中整欄皆為 NULL 的欄位。",
moreValues: "還有 {count} 個值,輸入關鍵字繼續縮小範圍",
@ -3669,6 +3673,7 @@ export default withEnglishFallback({
redisScanPageSizeDescription: "瀏覽 Redis Key 時每次 SCAN 請求的 Key 數量。",
redisScanPageSizeOption: "{count} 個 Key",
shortcutExecuteSql: "執行 SQL",
shortcutExecuteSqlInNewResultTab: "在新結果分頁中執行 SQL",
shortcutFind: "尋找",
shortcutReplace: "取代",
shortcutSaveSql: "儲存 SQL",

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { eventToModifierOnlyShortcut, eventToShortcut, matchesModifierOnlyShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts";
import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, matchesModifierOnlyShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts";
describe("keyboard shortcut matching", () => {
it("records modifier-only mouse shortcut settings", () => {
@ -32,6 +32,12 @@ describe("keyboard shortcut matching", () => {
expect(matchesShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Shift+Mod+Plus")).toBe(true);
});
it("matches the configurable execute-in-new-result-tab shortcut", () => {
expect(isExecuteSqlInNewResultTabShortcut({ key: "\\", ctrlKey: true }, { executeSqlInNewResultTab: "Mod+\\" })).toBe(true);
expect(isExecuteSqlInNewResultTabShortcut({ key: "\\", metaKey: true }, { executeSqlInNewResultTab: "Mod+\\" })).toBe(true);
expect(isExecuteSqlInNewResultTabShortcut({ key: "\\", ctrlKey: true, shiftKey: true }, { executeSqlInNewResultTab: "Mod+\\" })).toBe(false);
});
it("matches legacy plus-key shortcuts saved with plus as a separator", () => {
expect(matchesShortcut({ key: "+", ctrlKey: true }, "Mod++")).toBe(true);
expect(matchesShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Shift+Mod++")).toBe(true);

View File

@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
describe("QueryEditor execution routing", () => {
it("routes the execution shortcut through the shared execution-mode contract while bypassing the picker", () => {
@ -9,6 +10,15 @@ describe("QueryEditor execution routing", () => {
expect(queryEditorSource).not.toContain("forceCurrent");
});
it("routes the new-result-tab shortcut through the same target selection contract", () => {
expect(queryEditorSource).toContain("binding(shortcuts.executeSqlInNewResultTab, requestExecuteInNewResultTab)");
expect(queryEditorSource).toContain('emit("executeInNewResultTab", source)');
expect(queryEditorSource).toContain("requestExecute({ bypassPicker: true, openInNewResultTab: true })");
expect(contentAreaSource).toContain('const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs")');
expect(contentAreaSource).toContain("!!props.activeTab.resultRuns?.length");
expect(contentAreaSource).toContain('role="tablist" :aria-label="t(\'tabs.resultRuns\')"');
});
it("keeps selection priority and the configured current/all target choice", () => {
const selectionBranch = queryEditorSource.indexOf("if (!options.ignoreSelection && !selection.empty)");
const executeModeBranch = queryEditorSource.indexOf('settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all"');

View File

@ -30,6 +30,17 @@ describe("shortcutRegistry editor actions", () => {
expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.openDataInNewTab, "MacIntel")).toBe("Alt");
});
it("registers a conflict-free DBeaver-style shortcut for executing in a new result tab", () => {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "executeSqlInNewResultTab");
expect(definition).toMatchObject({ scope: "editor", defaultShortcut: "Mod+\\" });
expect(DEFAULT_SHORTCUT_SETTINGS.executeSqlInNewResultTab).toBe("Mod+\\");
expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.executeSqlInNewResultTab, "MacIntel")).toBe("Cmd+\\");
expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.executeSqlInNewResultTab, "Win32")).toBe("Ctrl+\\");
expect(shortcutToCodeMirrorKey(DEFAULT_SHORTCUT_SETTINGS.executeSqlInNewResultTab)).toBe("Mod-\\");
expect(findShortcutConflict("executeSqlInNewResultTab", DEFAULT_SHORTCUT_SETTINGS.executeSqlInNewResultTab, DEFAULT_SHORTCUT_SETTINGS)).toBeNull();
});
it("resolves the close-other-tabs default per platform and heals cross-platform synced defaults", () => {
// 本测试环境darwin默认应为 macOS 组合
expect(DEFAULT_SHORTCUT_SETTINGS.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut());

View File

@ -108,6 +108,10 @@ export function isExecuteSqlShortcut(event: ShortcutLikeEvent, shortcuts?: Parti
return matchesShortcut(event, actionShortcut("executeSql", shortcuts));
}
export function isExecuteSqlInNewResultTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("executeSqlInNewResultTab", shortcuts));
}
export function isCloseTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("closeTab", shortcuts));
}

View File

@ -2,6 +2,7 @@ import { isMacShortcutPlatform, parseShortcutStrokes, shortcutDisplayParts } fro
export type ShortcutActionId =
| "executeSql"
| "executeSqlInNewResultTab"
| "formatSql"
| "toggleLineComment"
| "saveSql"
@ -87,6 +88,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Mod+Enter",
},
{
id: "executeSqlInNewResultTab",
labelKey: "settings.shortcutExecuteSqlInNewResultTab",
scope: "editor",
defaultShortcut: "Mod+\\",
},
{
id: "formatSql",
labelKey: "settings.shortcutFormatSql",

View File

@ -3,16 +3,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
analyzeEditableQueryEditability: vi.fn(),
cancelQuery: vi.fn(),
closeClientConnectionSession: vi.fn(),
closeQuerySession: vi.fn(),
ensureConnected: vi.fn(),
executeMulti: vi.fn(),
getConnectionConfig: vi.fn(),
prepareQueryPaginationExecutionPlan: vi.fn(),
saveOpenTabsState: vi.fn(),
tabResultSnapshots: new Map<string, unknown>(),
}));
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability,
cancelQuery: mocks.cancelQuery,
closeClientConnectionSession: mocks.closeClientConnectionSession,
closeQuerySession: mocks.closeQuerySession,
executeMulti: mocks.executeMulti,
@ -22,7 +26,7 @@ vi.mock("@/lib/backend/api", () => ({
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
ensureConnected: mocks.ensureConnected,
getConfig: mocks.getConnectionConfig,
recordConnectionLostError: vi.fn(),
}),
@ -34,6 +38,21 @@ vi.mock("@/stores/settingsStore", () => ({
}),
}));
vi.mock("@/lib/tabs/tabResultCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
return {
...actual,
writeTabResultSnapshot: vi.fn(async (key: string, snapshot: unknown) => {
mocks.tabResultSnapshots.set(key, actual.decodeTabResultSnapshot(actual.encodeTabResultSnapshot(snapshot as Parameters<typeof actual.encodeTabResultSnapshot>[0])));
return true;
}),
readTabResultSnapshot: vi.fn(async (key: string) => mocks.tabResultSnapshots.get(key)),
deleteTabResultSnapshot: vi.fn(async (key: string) => {
mocks.tabResultSnapshots.delete(key);
}),
};
});
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
@ -43,12 +62,25 @@ function installLocalStorage() {
});
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe("queryStore multi-statement errors", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
mocks.tabResultSnapshots.clear();
installLocalStorage();
setActivePinia(createPinia());
mocks.cancelQuery.mockResolvedValue(true);
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.getConnectionConfig.mockReturnValue({
id: "mysql-1",
name: "MySQL",
@ -270,4 +302,199 @@ describe("queryStore multi-statement errors", () => {
expect(mocks.executeMulti).toHaveBeenCalledWith("mysql-1", "app", "SELECT 1", undefined, expect.any(String), expect.objectContaining({ continueOnError: false }));
});
it("keeps old and new executions as result runs, then lets normal execution replace the active run", async () => {
mocks.executeMulti
.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }])
.mockResolvedValueOnce([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }])
.mockResolvedValueOnce([{ columns: ["value"], rows: [[3]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
await store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.resultAutoSave).toBeUndefined();
expect(tab.resultRuns).toHaveLength(2);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[1]?.id);
expect(await store.setActiveResultRun(tabId, tab.resultRuns![0]!.id)).toBe(true);
expect(tab.result?.rows[0]?.[0]).toBe(1);
expect(await store.setActiveResultRun(tabId, tab.resultRuns![1]!.id)).toBe(true);
expect(tab.result?.rows[0]?.[0]).toBe(2);
await store.executeCurrentSql("SELECT 3 AS value");
expect(tab.resultRuns).toHaveLength(2);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[1]?.id);
expect(await store.setActiveResultRun(tabId, tab.resultRuns![0]!.id)).toBe(true);
expect(tab.result?.rows[0]?.[0]).toBe(1);
expect(await store.setActiveResultRun(tabId, tab.resultRuns![1]!.id)).toBe(true);
expect(tab.resultRuns?.[1]).toMatchObject({
sql: "SELECT 3 AS value",
result: { rows: [[3]] },
});
});
it("keeps ordinary executions in the single-result path by default", async () => {
mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.result?.rows).toEqual([[1]]);
expect(tab.resultRuns).toBeUndefined();
expect(tab.activeResultRunId).toBeUndefined();
});
it("restores the retained result when a new-result execution fails before dispatch", async () => {
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
mocks.ensureConnected.mockRejectedValueOnce(new Error("connection failed"));
await store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.resultRuns).toHaveLength(1);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[0]?.id);
expect(tab.result?.rows).toEqual([[1]]);
});
it("hydrates a restored active run before starting a new-result execution", async () => {
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
const tab = store.tabs.find((item) => item.id === tabId)!;
store.toggleResultAutoSave(tabId);
const run = tab.resultRuns?.[0];
expect(run?.resultCacheKey).toBeTruthy();
run!.result = undefined;
run!.results = undefined;
tab.result = undefined;
tab.results = undefined;
mocks.ensureConnected.mockRejectedValueOnce(new Error("connection failed"));
await store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
expect(tab.resultRuns).toHaveLength(1);
expect(tab.activeResultRunId).toBe(run?.id);
expect(tab.result?.rows).toEqual([[1]]);
expect(mocks.tabResultSnapshots.has(run!.resultCacheKey!)).toBe(true);
run!.result = undefined;
run!.results = undefined;
tab.result = undefined;
tab.results = undefined;
expect(await store.setActiveResultRun(tabId, run!.id)).toBe(true);
expect(tab.result?.rows).toEqual([[1]]);
});
it("captures a new run when another retained run is selected during execution", async () => {
const pendingExecution = deferred<Array<{ columns: string[]; rows: number[][]; affected_rows: number; execution_time_ms: number }>>();
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockImplementationOnce(() => pendingExecution.promise);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
store.toggleResultAutoSave(tabId);
const tab = store.tabs.find((item) => item.id === tabId)!;
const retainedRunId = tab.activeResultRunId!;
const retainedRunCacheKey = tab.resultRuns?.find((run) => run.id === retainedRunId)?.resultCacheKey;
const execution = store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
expect(await store.setActiveResultRun(tabId, retainedRunId)).toBe(true);
pendingExecution.resolve([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]);
await execution;
expect(tab.resultRuns).toHaveLength(2);
expect(tab.activeResultRunId).not.toBe(retainedRunId);
expect(tab.result).toMatchObject({ rows: [[2]] });
expect(tab.resultRuns?.find((run) => run.id === tab.activeResultRunId)?.resultCacheKey).not.toBe(retainedRunCacheKey);
expect(await store.setActiveResultRun(tabId, retainedRunId)).toBe(true);
expect(tab.result).toMatchObject({ rows: [[1]] });
});
it("restores the retained result when a new-result execution returns no result", async () => {
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
await store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.resultRuns).toHaveLength(1);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[0]?.id);
expect(tab.result?.rows).toEqual([[1]]);
});
it("restores the retained result when a new-result execution is cancelled", async () => {
const pendingExecution = deferred<never>();
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockImplementationOnce(() => pendingExecution.promise);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
const execution = store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
await expect(store.cancelTabExecution(tabId)).resolves.toBe(true);
pendingExecution.reject(new Error("Query canceled"));
await execution;
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.resultRuns).toHaveLength(1);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[0]?.id);
expect(tab.result?.rows).toEqual([[1]]);
});
it("keeps the retained result when a cancelled execution still returns data", async () => {
const pendingExecution = deferred<Array<{ columns: string[]; rows: number[][]; affected_rows: number; execution_time_ms: number }>>();
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockImplementationOnce(() => pendingExecution.promise);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
const execution = store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
await expect(store.cancelTabExecution(tabId)).resolves.toBe(true);
pendingExecution.resolve([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]);
await execution;
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.resultRuns).toHaveLength(1);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[0]?.id);
expect(tab.result?.rows).toEqual([[1]]);
});
it("restores the adjacent retained result after closing the active run", async () => {
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeCurrentSql("SELECT 1 AS value");
await store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
const tab = store.tabs.find((item) => item.id === tabId)!;
const activeRunId = tab.activeResultRunId!;
expect(await store.removeResultRun(tabId, activeRunId)).toBe(true);
expect(tab.resultRuns).toHaveLength(1);
expect(tab.activeResultRunId).toBe(tab.resultRuns?.[0]?.id);
expect(tab.result?.rows).toEqual([[1]]);
});
});

View File

@ -115,6 +115,12 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ dataGridSearchMode: "invalid" as any }).dataGridSearchMode).toBe("filter");
});
it("defaults retained result runs to tiled tabs and preserves list mode", () => {
expect(normalizeEditorSettings({}).resultRunDisplayMode).toBe("tabs");
expect(normalizeEditorSettings({ resultRunDisplayMode: "list" }).resultRunDisplayMode).toBe("list");
expect(normalizeEditorSettings({ resultRunDisplayMode: "invalid" as any }).resultRunDisplayMode).toBe("tabs");
});
it("defaults persistent data grid view options off and preserves enabled values", () => {
const defaults = normalizeEditorSettings({});
expect(defaults.dataGridMultiRowTranspose).toBe(false);
@ -348,6 +354,19 @@ describe("settingsStore sidebar connection sort persistence", () => {
await store.persistEditorSettings();
expect(isProxy(saveEditorSettings.mock.calls[1][0])).toBe(false);
});
it("persists the retained result run display mode", async () => {
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
store.updateEditorSettings({ resultRunDisplayMode: "list" });
expect(store.editorSettings.resultRunDisplayMode).toBe("list");
expect(saveEditorSettings).toHaveBeenCalledWith(expect.objectContaining({ resultRunDisplayMode: "list" }));
expect(isProxy(saveEditorSettings.mock.calls[0][0])).toBe(false);
});
});
// --- activeModel lifecycle tests ---

View File

@ -480,6 +480,7 @@ export const useQueryStore = defineStore("query", () => {
const tableStructureRefreshVersions = ref<Record<string, number>>({});
const savedSqlEditorPositionTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingTabSessionResets = new Map<string, Promise<void>>();
const pendingResultRunRestores = new Map<string, string>();
let resultCacheTrimScheduled = false;
let resultCacheTrimRunning = false;
let resultCacheTrimRequested = false;
@ -597,7 +598,7 @@ export const useQueryStore = defineStore("query", () => {
}
}
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean } = {}) {
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean; preserveCacheSnapshot?: boolean } = {}) {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
@ -617,7 +618,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultEvicted = options.evicted ? true : undefined;
tab.resultCacheState = options.evicted ? tab.resultCacheState : undefined;
if (!options.evicted) {
if (tab.resultCacheKey) void deleteTabResultSnapshot(tab.resultCacheKey);
if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey);
tab.resultCacheKey = undefined;
}
}
@ -682,6 +683,17 @@ export const useQueryStore = defineStore("query", () => {
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
}
function restorePendingResultRun(tab: QueryTab, executionId: string): boolean {
const runId = pendingResultRunRestores.get(executionId);
pendingResultRunRestores.delete(executionId);
if (!runId) return false;
const run = tab.resultRuns?.find((item) => item.id === runId);
if (!run || !resultRunHasPayload(run)) return false;
projectResultRun(tab, run);
evictInactiveResultRunPayloads(tab);
return true;
}
async function restoreResultRunPayload(tab: QueryTab, runId: string) {
const run = tab.resultRuns?.find((item) => item.id === runId);
if (!run || run.result || run.results?.length) return run;
@ -792,7 +804,7 @@ export const useQueryStore = defineStore("query", () => {
}
}
function captureDisplayedResultRun(tab: QueryTab, sql: string, createdAt = Date.now()) {
function captureDisplayedResultRun(tab: QueryTab, sql: string, createdAt = Date.now(), options: { reuseResultCacheKey?: boolean } = {}) {
if (tab.mode !== "query" || !tab.result) return;
const sequence = nextResultRunSequence(tab);
const run: NonNullable<QueryTab["resultRuns"]>[number] = {
@ -824,7 +836,7 @@ export const useQueryStore = defineStore("query", () => {
resultSessionId: tab.resultSessionId,
resultAccessedAt: tab.resultAccessedAt,
resultEstimatedBytes: tab.resultEstimatedBytes,
resultCacheKey: tab.resultCacheKey,
resultCacheKey: options.reuseResultCacheKey === false ? undefined : tab.resultCacheKey,
resultCacheState: tab.resultCacheState,
resultEvicted: tab.resultEvicted,
queryAnalysis: tab.queryAnalysis,
@ -836,6 +848,10 @@ export const useQueryStore = defineStore("query", () => {
void persistResultRun(tab, run);
tab.resultRuns = [...(tab.resultRuns ?? []), run];
tab.activeResultRunId = run.id;
if (options.reuseResultCacheKey === false) {
tab.resultCacheKey = run.resultCacheKey;
tab.resultCacheState = run.resultCacheState;
}
evictInactiveResultRunPayloads(tab);
}
@ -849,12 +865,13 @@ export const useQueryStore = defineStore("query", () => {
return tab.resultAutoSave === true;
}
function syncActiveResultRunFromDisplayed(tab: QueryTab) {
function syncActiveResultRunFromDisplayed(tab: QueryTab, sql?: string) {
if (!tab.activeResultRunId || !tab.resultRuns?.length) return;
const index = tab.resultRuns.findIndex((run) => run.id === tab.activeResultRunId);
if (index < 0) return;
const run = {
...tab.resultRuns[index],
...(sql ? { sql } : {}),
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
@ -891,10 +908,12 @@ export const useQueryStore = defineStore("query", () => {
tab.resultRuns[index] = run;
}
function syncDisplayedResultRun(tab: QueryTab, sql: string) {
function syncDisplayedResultRun(tab: QueryTab, sql: string, captureNewRun = false) {
if (tab.mode !== "query" || !tab.result) return;
if (tab.activeResultRunId) {
syncActiveResultRunFromDisplayed(tab);
if (captureNewRun) {
captureDisplayedResultRun(tab, sql, Date.now(), { reuseResultCacheKey: false });
} else if (tab.activeResultRunId) {
syncActiveResultRunFromDisplayed(tab, sql);
} else if (tab.resultAutoSave) {
captureDisplayedResultRun(tab, sql);
}
@ -2458,16 +2477,21 @@ export const useQueryStore = defineStore("query", () => {
function clearAcknowledgedCancelIfStillRunning(id: string, executionId: string) {
setTimeout(() => {
const current = tabs.value.find((t) => t.id === id);
if (!current || current.executionId !== executionId || !current.isCancelling) return;
if (!current || current.executionId !== executionId || !current.isCancelling) {
pendingResultRunRestores.delete(executionId);
return;
}
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
current.queryExecutionStartedAt = undefined;
current.result = toErrorResult(new Error("Query canceled"));
current.results = undefined;
current.activeResultIndex = undefined;
current.resultSessionId = undefined;
touchResult(current);
if (!restorePendingResultRun(current, executionId)) {
current.result = toErrorResult(new Error("Query canceled"));
current.results = undefined;
current.activeResultIndex = undefined;
current.resultSessionId = undefined;
touchResult(current);
}
}, CANCEL_ACK_SETTLE_TIMEOUT_MS);
}
@ -2478,7 +2502,7 @@ export const useQueryStore = defineStore("query", () => {
await executeCurrentSql(tab.sql);
}
async function executeCurrentSql(sql: string, options?: { skipRedisSafetyCheck?: boolean; sourceOffset?: number }) {
async function executeCurrentSql(sql: string, options?: { skipRedisSafetyCheck?: boolean; sourceOffset?: number; openInNewResultTab?: boolean }) {
if (!activeTabId.value) return;
const tab = tabs.value.find((item) => item.id === activeTabId.value);
if (tab?.mode === "query") {
@ -2488,7 +2512,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortMode = undefined;
tab.resultSortedSql = undefined;
}
await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined, ...options });
return await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined, ...options });
}
type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta">;
@ -2981,11 +3005,17 @@ export const useQueryStore = defineStore("query", () => {
sourceOffset?: number;
sourceTraceId?: string;
skipEnsureConnected?: boolean;
openInNewResultTab?: boolean;
},
) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || !sql.trim()) return;
const openInNewResultTab = tab.mode === "query" && options?.openInNewResultTab === true;
if (openInNewResultTab && tab.activeResultRunId && !tab.result) {
await setActiveResultRun(id, tab.activeResultRunId);
if (tabs.value.find((item) => item.id === id) !== tab) return false;
}
const executionId = uuid();
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
const traceId = executionId.slice(0, 8);
@ -2997,11 +3027,19 @@ export const useQueryStore = defineStore("query", () => {
tab.queryExecutionStartedAt = Date.now();
}
tab.executionId = executionId;
const previousDisplayedSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
tab.lastExecutedSql = sql;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
const updateActiveResultRun = !!tab.activeResultRunId && options?.preserveResultDuringExecution === true;
if (openInNewResultTab && tab.result && !tab.activeResultRunId) {
captureDisplayedResultRun(tab, previousDisplayedSql);
}
if (openInNewResultTab && tab.activeResultRunId) {
pendingResultRunRestores.set(executionId, tab.activeResultRunId);
}
const preserveResultDuringExecution = options?.preserveResultDuringExecution === true || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !openInNewResultTab);
const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution;
if (!updateActiveResultRun) {
tab.activeResultRunId = undefined;
}
@ -3010,8 +3048,8 @@ export const useQueryStore = defineStore("query", () => {
}
tab.resultTotalRowCountLoading = false;
const previousResultSessionClose = closeResultSession(tab, options?.pagination?.sessionId);
if (!options?.preserveResultDuringExecution || !tab.result) {
clearResultPayload(tab);
if (!preserveResultDuringExecution || !tab.result) {
clearResultPayload(tab, { preserveCacheSnapshot: openInNewResultTab && pendingResultRunRestores.has(executionId) });
}
queryExecutionLog("info", "start", {
traceId,
@ -3030,6 +3068,8 @@ export const useQueryStore = defineStore("query", () => {
let pageOffset: number | undefined;
let countSql: string | undefined;
let useAgentResultSession = false;
let executionDispatched = false;
let producedResult = false;
try {
await waitForTabSessionReset(id);
const connStore = useConnectionStore();
@ -3068,7 +3108,7 @@ export const useQueryStore = defineStore("query", () => {
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (commands.length === 0) return;
if (commands.length === 0) return false;
queryExecutionLog("info", "redis:start", { traceId, db: currentDb, commandCount: commands.length, sqlLength: sql.length });
const allResults: QueryResult[] = [];
@ -3097,6 +3137,7 @@ export const useQueryStore = defineStore("query", () => {
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (allResults.length > 1) {
const activeResultIndex = allResults.findIndex((r) => !r.columns.includes("Error"));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
@ -3108,6 +3149,7 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined;
current.result = allResults[0];
}
producedResult = current.result !== undefined;
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
@ -3116,7 +3158,7 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = options?.resultSortedSql;
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, options?.resultBaseSql ?? sql, openInNewResultTab);
// Reflect db switches from SELECT N in the tab so the toolbar dropdown, tab title and
// sidebar stay in sync with the command's effective db.
if (current.database !== String(currentDb)) {
@ -3129,7 +3171,7 @@ export const useQueryStore = defineStore("query", () => {
if (hadMutatingCommand) {
void connStore.refreshRedisDbKeyCounts(tab.connectionId);
}
return;
return producedResult;
}
if (conn?.db_type === "mongodb" && mongoCommands.length === 0 && sql.trim()) {
@ -3396,6 +3438,7 @@ export const useQueryStore = defineStore("query", () => {
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
const activeGroupIndex = current.activeResultIndex;
const activeGroupResults = current.results;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && allResults.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
@ -3416,6 +3459,7 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined;
current.result = allResults[0];
}
producedResult = current.result !== undefined;
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
@ -3424,10 +3468,10 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? options?.resultBaseSql ?? sql) : (options?.resultBaseSql ?? sql);
current.resultSortedSql = options?.resultSortedSql;
syncDisplayedResultRun(current, current.resultBaseSql ?? options?.resultBaseSql ?? sql);
syncDisplayedResultRun(current, current.resultBaseSql ?? options?.resultBaseSql ?? sql, openInNewResultTab);
if (current.database !== currentDatabase) current.database = currentDatabase;
}
return;
return producedResult;
}
const elasticsearchRequests = elasticsearchRestRequestRanges(sqlToExecute, effectiveDbType);
@ -3464,12 +3508,14 @@ export const useQueryStore = defineStore("query", () => {
elapsed: elapsed(),
});
const current = tabs.value.find((item) => item.id === id);
if (current?.executionId === executionId && openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (current?.executionId === executionId && allResults.length > 0) {
const errorResultIndex = allResults.findIndex((result) => result.columns.includes("Error") || elasticsearchHttpErrorStatus(result) !== undefined);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
current.results = allResults.length > 1 ? allResults : undefined;
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
current.result = allResults[resultIndex];
producedResult = current.result !== undefined;
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
@ -3478,9 +3524,9 @@ export const useQueryStore = defineStore("query", () => {
current.tableMeta = undefined;
current.resultBaseSql = options?.resultBaseSql ?? sql;
current.resultSortedSql = undefined;
syncDisplayedResultRun(current, current.resultBaseSql);
syncDisplayedResultRun(current, current.resultBaseSql, openInNewResultTab);
}
return;
return producedResult;
}
if (tab.mode === "query") {
@ -3533,6 +3579,7 @@ export const useQueryStore = defineStore("query", () => {
queryExecutionLog("info", "begin-manual-txn:done", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
}
queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
executionDispatched = true;
executionPromise = api.executeInManualTransaction(tab.txnSessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit);
} else {
queryExecutionLog("info", "execute-multi:start", { traceId, elapsed: elapsed() });
@ -3563,6 +3610,7 @@ export const useQueryStore = defineStore("query", () => {
optionKeys: Object.keys(executionOptions),
clientSession: Boolean(clientSessionId),
});
executionDispatched = true;
executionPromise = api.executeMulti(tab.connectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
}
const results = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset);
@ -3583,6 +3631,7 @@ export const useQueryStore = defineStore("query", () => {
});
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (successfulOracleSchemaChanges > 0) {
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges;
}
@ -3617,6 +3666,7 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined;
current.result = results[0];
}
producedResult = current.result !== undefined;
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
@ -3662,7 +3712,7 @@ export const useQueryStore = defineStore("query", () => {
totalRowCountResolved = true;
}
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql);
syncDisplayedResultRun(current, queryBaseSql, openInNewResultTab);
if (!options?.appendResult && !totalRowCountResolved && (current.mode === "query" || current.mode === "data") && current.result) {
countQueryTotalRowsInBackground({
tabId: id,
@ -3719,11 +3769,15 @@ export const useQueryStore = defineStore("query", () => {
}
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (options?.appendResult && current.result) {
const restoredRetainedResult = openInNewResultTab && (current.isCancelling || !executionDispatched) && restorePendingResultRun(current, executionId);
if (restoredRetainedResult) {
queryExecutionLog("info", "retained-result:restored-after-abort", { traceId, elapsed: elapsed() });
return false;
} else if (options?.appendResult && current.result) {
// A failed background segment must not replace the visible result or
// silently invalidate pending edits. The next explicit refresh can retry.
queryExecutionLog("warn", "append-result:preserved-after-error", { traceId, elapsed: elapsed() });
return;
return false;
}
const errorResult = toErrorResult(e);
const activeGroupIndex = current.activeResultIndex;
@ -3753,17 +3807,24 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCount = undefined;
current.resultTotalRowCountLoading = false;
touchResult(current);
syncDisplayedResultRun(current, queryBaseSql);
producedResult = true;
syncDisplayedResultRun(current, queryBaseSql, openInNewResultTab);
}
} finally {
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
if (openInNewResultTab && !current.activeResultRunId) {
restorePendingResultRun(current, executionId);
} else {
pendingResultRunRestores.delete(executionId);
}
current.isExecuting = false;
current.isCancelling = false;
current.queryExecutionStartedAt = undefined;
current.executionId = undefined;
queryExecutionLog("info", "finish", { traceId, elapsed: elapsed() });
} else {
pendingResultRunRestores.delete(executionId);
queryExecutionLog("warn", "finish-stale", {
traceId,
currentExecutionId: current?.executionId,
@ -3772,6 +3833,7 @@ export const useQueryStore = defineStore("query", () => {
}
}
scheduleResultCacheTrim();
return producedResult;
}
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
@ -4118,6 +4180,7 @@ export const useQueryStore = defineStore("query", () => {
if (!canceled) {
const current = tabs.value.find((t) => t.id === id);
if (current && current.executionId === executionId) {
restorePendingResultRun(current, executionId);
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
@ -4130,9 +4193,16 @@ export const useQueryStore = defineStore("query", () => {
if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e);
const current = tabs.value.find((t) => t.id === id);
if (current && current.executionId === executionId) {
// 复用 setErrorResult 的完整清理:分组结果不清空的话,错误结果不会展示,
// 估算值也会继续按旧的 results 计算
setErrorResult(id, e);
if (restorePendingResultRun(current, executionId)) {
current.isExecuting = false;
current.isCancelling = false;
current.queryExecutionStartedAt = undefined;
current.executionId = undefined;
} else {
// 复用 setErrorResult 的完整清理:分组结果不清空的话,错误结果不会展示,
// 估算值也会继续按旧的 results 计算
setErrorResult(id, e);
}
}
return false;
} finally {

View File

@ -311,6 +311,8 @@ const DATA_GRID_RENDER_MODES = ["dom", "canvas"] as const;
export type DataGridRenderMode = (typeof DATA_GRID_RENDER_MODES)[number];
const DATA_GRID_SEARCH_MODES = ["filter", "highlight"] as const;
export type DataGridSearchMode = (typeof DATA_GRID_SEARCH_MODES)[number];
const RESULT_RUN_DISPLAY_MODES = ["tabs", "list"] as const;
export type ResultRunDisplayMode = (typeof RESULT_RUN_DISPLAY_MODES)[number];
export const TABLE_FONT_SIZE_MIN = 8;
export const TABLE_FONT_SIZE_MAX = 16;
export const TABLE_FONT_SIZE_DEFAULT = 13;
@ -418,6 +420,7 @@ export interface EditorSettings {
dataGridQuickEntry: boolean;
dataGridRenderMode: DataGridRenderMode;
dataGridSearchMode: DataGridSearchMode;
resultRunDisplayMode: ResultRunDisplayMode;
dataGridAutoTransposeSingleRow: boolean;
dataGridMultiRowTranspose: boolean;
dataGridHideNullColumns: boolean;
@ -582,6 +585,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
dataGridQuickEntry: false,
dataGridRenderMode: "canvas",
dataGridSearchMode: "filter",
resultRunDisplayMode: "tabs",
dataGridAutoTransposeSingleRow: false,
dataGridMultiRowTranspose: false,
dataGridHideNullColumns: false,
@ -674,6 +678,10 @@ function normalizeDataGridSearchMode(value: unknown): DataGridSearchMode {
return DATA_GRID_SEARCH_MODES.includes(value as DataGridSearchMode) ? (value as DataGridSearchMode) : DEFAULT_EDITOR_SETTINGS.dataGridSearchMode;
}
function normalizeResultRunDisplayMode(value: unknown): ResultRunDisplayMode {
return RESULT_RUN_DISPLAY_MODES.includes(value as ResultRunDisplayMode) ? (value as ResultRunDisplayMode) : DEFAULT_EDITOR_SETTINGS.resultRunDisplayMode;
}
function normalizeTableFontSize(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return TABLE_FONT_SIZE_DEFAULT;
return Math.min(TABLE_FONT_SIZE_MAX, Math.max(TABLE_FONT_SIZE_MIN, Math.round(value)));
@ -861,6 +869,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
dataGridQuickEntry: settings.dataGridQuickEntry ?? DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry,
dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode),
dataGridSearchMode: normalizeDataGridSearchMode(settings.dataGridSearchMode),
resultRunDisplayMode: normalizeResultRunDisplayMode(settings.resultRunDisplayMode),
dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true,
dataGridMultiRowTranspose: settings.dataGridMultiRowTranspose === true,
dataGridHideNullColumns: settings.dataGridHideNullColumns === true,
@ -1233,6 +1242,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.dataGridQuickEntry !== undefined) editorSettings.value.dataGridQuickEntry = partial.dataGridQuickEntry;
if (partial.dataGridRenderMode !== undefined) editorSettings.value.dataGridRenderMode = normalizeDataGridRenderMode(partial.dataGridRenderMode);
if (partial.dataGridSearchMode !== undefined) editorSettings.value.dataGridSearchMode = normalizeDataGridSearchMode(partial.dataGridSearchMode);
if (partial.resultRunDisplayMode !== undefined) editorSettings.value.resultRunDisplayMode = normalizeResultRunDisplayMode(partial.resultRunDisplayMode);
if (partial.dataGridAutoTransposeSingleRow !== undefined) editorSettings.value.dataGridAutoTransposeSingleRow = partial.dataGridAutoTransposeSingleRow === true;
if (partial.dataGridMultiRowTranspose !== undefined) editorSettings.value.dataGridMultiRowTranspose = partial.dataGridMultiRowTranspose === true;
if (partial.dataGridHideNullColumns !== undefined) editorSettings.value.dataGridHideNullColumns = partial.dataGridHideNullColumns === true;

View File

@ -12,6 +12,7 @@ DBX 为常用操作提供可配置的键盘快捷键。使用默认快捷键或
| 快捷键 | 操作 |
| -------------------------------------- | ---------------- |
| `Ctrl+Enter` / `Cmd+Enter` | 执行当前查询 |
| `Ctrl+\` / `Cmd+\` | 在新结果标签页中执行 |
| `Ctrl+Shift+Enter` / `Cmd+Shift+Enter` | 执行所有查询 |
| `Ctrl+/` / `Cmd+/` | 切换行注释 |
| `Ctrl+F` / `Cmd+F` | 在编辑器中搜索 |
@ -78,5 +79,5 @@ DBX 为常用操作提供可配置的键盘快捷键。使用默认快捷键或
如果自定义快捷键与现有快捷键冲突:
- DBX 会在保存前发出警告
- 你可以选择保留冲突或选择其他组合
- 解决冲突前DBX 不会应用已经修改的快捷键
- 系统快捷键(如 macOS 的 `Cmd+Q`)无法被覆盖

View File

@ -12,6 +12,7 @@ DBX provides configurable keyboard shortcuts for common actions. Use the default
| Shortcut | Action |
| -------------------------------------- | ------------------------------ |
| `Ctrl+Enter` / `Cmd+Enter` | Execute current query |
| `Ctrl+\` / `Cmd+\` | Execute in a new result tab |
| `Ctrl+Shift+Enter` / `Cmd+Shift+Enter` | Execute all queries |
| `Ctrl+/` / `Cmd+/` | Toggle line comment |
| `Ctrl+F` / `Cmd+F` | Search in editor |
@ -78,5 +79,5 @@ Set these in **Settings → Shortcuts** to match your preferred workflow.
If a custom shortcut conflicts with an existing one:
- DBX warns you before saving
- You can choose to keep the conflict or choose a different combination
- DBX blocks applying the changed shortcuts until the conflict is resolved
- System shortcuts (like `Cmd+Q` on macOS) cannot be overridden

View File

@ -36,15 +36,20 @@ description: 使用 DBX 的 SQL 编辑器编写、补全、格式化、执行和
## 执行 SQL
| 操作 | macOS | Windows / Linux |
| ------------ | ---------------------- | ----------------------- |
| 执行全部 SQL | `Cmd+Enter` | `Ctrl+Enter` |
| 执行选中 SQL | 选中文本后 `Cmd+Enter` | 选中文本后 `Ctrl+Enter` |
| 操作 | macOS | Windows / Linux |
| ---------------------- | ---------------------- | ----------------------- |
| 执行全部 SQL | `Cmd+Enter` | `Ctrl+Enter` |
| 执行选中 SQL | 选中文本后 `Cmd+Enter` | 选中文本后 `Ctrl+Enter` |
| 在新结果标签页中执行 | `Cmd+\` | `Ctrl+\` |
<Callout type="info">如果编辑器中有选中文本DBX 只执行选中的 SQL没有选中文本时执行当前编辑器中的全部内容。</Callout>
执行完成后,结果区域会展示返回行、耗时、影响行数或错误信息。多语句执行时,建议先选中要执行的片段,减少误操作。
普通执行会覆盖当前激活的结果。需要保留当前结果并对比下一次执行时,使用**在新结果标签页中执行 SQL**DBX 默认把每次执行平铺为横向结果标签,可直接切换或关闭。标签较多时仅执行标签区横向滚动,当前表名会保持固定。也可以在结果工具栏的**视图选项**中把执行结果切换为列表。两种执行方式对选中 SQL 和当前语句的判定规则完全一致。
两种执行快捷键都可以在**设置 → 快捷键**中配置。DBX 会检测 SQL 编辑器作用域内的按键冲突,解决冲突后才能应用快捷键变更。
DBX 也会跟踪查询会话,离开结果集时可以关闭会话;当数据库驱动支持取消时,正在执行的查询也可以从界面中取消。
### SQL 执行目标选择器

View File

@ -36,15 +36,20 @@ The query editor is where daily SQL work happens: writing queries, using complet
## Execute SQL
| Action | macOS | Windows / Linux |
| -------------------- | ----------------------------- | ------------------------------ |
| Execute all SQL | `Cmd+Enter` | `Ctrl+Enter` |
| Execute selected SQL | Select text, then `Cmd+Enter` | Select text, then `Ctrl+Enter` |
| Action | macOS | Windows / Linux |
| --------------------------- | ----------------------------- | ------------------------------ |
| Execute all SQL | `Cmd+Enter` | `Ctrl+Enter` |
| Execute selected SQL | Select text, then `Cmd+Enter` | Select text, then `Ctrl+Enter` |
| Execute in a new result tab | `Cmd+\` | `Ctrl+\` |
<Callout type="info">When text is selected, DBX executes only the selected SQL. When nothing is selected, it executes the full editor content.</Callout>
After execution, the result area shows returned rows, duration, affected row count, or the error message. For multi-statement scripts, select the exact fragment you want to run to reduce mistakes.
Regular execution replaces the active result. Use **Execute SQL in new result tab** when you need to keep the current result and compare it with another run. By default, DBX tiles every run as a horizontal result tab that you can switch or close directly. When many runs are retained, only the run-tab strip scrolls while the current table label stays fixed. You can switch retained runs to a compact list under **View options** in the result toolbar. The selected SQL and current-statement rules are the same for both execution actions.
Both execution shortcuts are configurable under **Settings → Shortcuts**. DBX reports conflicts within the SQL editor scope and requires them to be resolved before applying shortcut changes.
DBX also tracks query sessions so long-running result sets can be closed when you leave them, and active queries can be cancelled from the UI when the database driver supports cancellation.
### SQL Execution Target Picker

View File

@ -42,11 +42,26 @@ test("query result toolbar reuses the production icon contract", () => {
assert.doesNotMatch(viewSwitcher + toolbarActions, /<svg\b|<symbol\b|<use\b/);
});
test("ContentArea keeps result history conditional and removes duplicate refresh state", () => {
test("ContentArea exposes retained result runs as switchable tabs or a compact list", () => {
const contentArea = source(contentAreaPath);
const runScrollerStart = contentArea.indexOf('ref="resultTabsScrollerRef"');
const listSelectorStart = contentArea.indexOf('<div v-else-if="showResultRunSelector"', runScrollerStart);
const fixedResultSetStart = contentArea.indexOf("data-result-set-tabs-region", listSelectorStart);
assert.match(contentArea, /showResultRunSelector = computed\(\(\) => resultAutoSave\.value && resultRuns\.value\.length > 0\)/);
assert.match(contentArea, /<template v-if="showResultRunSelector">/);
assert.match(contentArea, /showResultRunTabs = computed\(\(\) => resultRuns\.value\.length > 0 && resultRunDisplayMode\.value === "tabs"\)/);
assert.match(contentArea, /showResultRunSelector = computed\(\(\) => resultRuns\.value\.length > 0 && resultRunDisplayMode\.value === "list"\)/);
assert.match(contentArea, /ref="resultTabsScrollerRef"[\s\S]*v-for="\(run, runIndex\) in resultRuns"/);
assert.match(contentArea, /role="tablist" :aria-label="t\('tabs\.resultRuns'\)"/);
assert.match(contentArea, /data-result-run-tab/);
assert.match(contentArea, /@keydown="onResultRunTabKeydown\(\$event, runIndex\)"/);
assert.match(contentArea, /@click\.stop\.prevent="removeResultRun\(run\.id\)"/);
assert.match(contentArea, /<DropdownMenuContent align="start" class="w-48">[\s\S]*v-for="run in resultRuns"/);
assert.match(contentArea, /setResultRunDisplayMode\('list'\)/);
assert.match(contentArea, /setResultRunDisplayMode\('tabs'\)/);
assert.ok(runScrollerStart >= 0);
assert.ok(listSelectorStart > runScrollerStart);
assert.ok(fixedResultSetStart > listSelectorStart);
assert.doesNotMatch(contentArea.slice(runScrollerStart, listSelectorStart), /visibleResultItems/);
assert.match(contentArea, /resultAutoSave \? 'bg-primary\/10 text-primary[\s\S]*: 'text-muted-foreground hover:bg-accent hover:text-foreground'/);
assert.doesNotMatch(contentArea, /queryResultAutoRefresh|QUERY_RESULT_AUTO_REFRESH|nextResultToolbarLayout/);
assert.equal((contentArea.match(/<QueryResultViewSwitcher\b/g) ?? []).length, 2);