diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index f02d8e10f..11e6db8ad 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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)" diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index a8a91a457..fb5faa9d8 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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(() => { 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, diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 7e035b0f6..9775d3bf9 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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(); @@ -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('[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('[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("[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 { 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 });