feat(editor): retain multiple query result runs
This commit is contained in:
parent
fddc0fdf50
commit
7f20b03177
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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を保存",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`)无法被覆盖
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 执行目标选择器
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue