feat(toolbar): redesign query result toolbar layout

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-12 00:30:43 +08:00
1 parent fec91b9aef
commit add57d3ae0
13 files changed
+592 -362

No files matched your search

+20 -4
View File
@@ -177,6 +177,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { supportsTableStructureEditing } from "@/lib/database/databaseCapabilities";
@@ -280,7 +281,6 @@ const dataGridElapsed = () => `${Math.round(performance.now() - dataGridCreatedA
const isMac = isMacOS();
const shortcutMod = isMac ? "Cmd" : "Ctrl";
const saveShortcutLabel = computed(() => formatShortcut(settingsStore.editorSettings.shortcuts.saveSql));
const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900;
const AUTO_REFRESH_INTERVAL_OPTIONS = [5, 10, 30, 60, 300];
function logDataGridTiming(message: string, payload?: Record<string, unknown>) {
@@ -288,7 +288,7 @@ function logDataGridTiming(message: string, payload?: Record<string, unknown>) {
}
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
"update:whereInput": [value: string];
@@ -4063,6 +4063,8 @@ const saveToolbarState = computed(() =>
}),
);
const hasSearchBarSlot = computed(() => !!slots["search-bar"]);
const hasResultToolbarLeadingSlot = computed(() => !!slots["result-toolbar-leading"]);
const hasResultToolbarActionsSlot = computed(() => !!slots["result-toolbar-actions"]);
const quickEntryEnabled = computed(() => settingsStore.editorSettings.dataGridQuickEntry);
const showQuickEntryDraftRow = computed(() =>
shouldShowQuickEntryDraftRow({
@@ -4079,6 +4081,8 @@ const showDataGridTopbar = computed(
hasLocalColumnFilters.value ||
canShowWhereSearch.value ||
hasSearchBarSlot.value ||
hasResultToolbarLeadingSlot.value ||
hasResultToolbarActionsSlot.value ||
showQueryEditReadOnlyBadge.value ||
props.context !== "results" ||
(!!props.editable && hasDataGridSaveTarget.value) ||
@@ -4236,7 +4240,7 @@ async function onToolbarRefresh() {
}
preserveTransposeOnNextResult.value = showTranspose.value;
isRefreshingData.value = true;
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value);
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value, "refresh");
}
function stopAutoRefreshTimer() {
@@ -8731,6 +8735,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div v-if="hasData || canShowWhereSearch" class="flex-1 flex flex-col overflow-hidden" @contextmenu="onContextMenu">
<!-- Search bar -->
<div ref="dataGridTopbarRef" v-if="showDataGridTopbar" class="data-grid-topbar-shell shrink-0 flex min-w-0 border-b bg-muted/20">
<div v-if="hasResultToolbarLeadingSlot" class="flex shrink-0 items-center border-r">
<slot name="result-toolbar-leading" :compact="compactDataGridToolbar" />
</div>
<div
class="data-grid-topbar-scroll min-w-0 flex-1 overflow-x-hidden"
@scroll="
@@ -9159,6 +9166,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
<div class="flex shrink-0 items-center gap-1 px-1 ml-auto" @wheel="onDataGridTopbarFixedActionWheel">
<slot v-if="hasResultToolbarActionsSlot" name="result-toolbar-actions" :compact="compactDataGridToolbar" />
<Tooltip v-if="showQueryEditReadOnlyBadge">
<TooltipTrigger as-child>
<div class="flex h-5 items-center gap-1 rounded border border-muted-foreground/30 bg-muted/60 px-1.5 text-xs font-medium text-muted-foreground">
@@ -11460,9 +11468,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
}
.data-grid-topbar-action-button {
align-items: center;
justify-content: center;
max-width: 9rem;
min-width: 1.25rem;
gap: 0;
line-height: 1;
overflow: hidden;
transition:
max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
@@ -11484,6 +11495,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
}
.data-grid-topbar-action-icon {
display: block;
align-self: center;
flex-shrink: 0;
transition:
margin-inline-end var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
@@ -11507,10 +11520,13 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
}
.data-grid-topbar-action-label {
display: inline-block;
display: inline-flex;
align-items: center;
height: 1rem;
max-width: 8rem;
overflow: hidden;
white-space: nowrap;
line-height: 1;
opacity: 1;
transform: translateX(0);
transition:
@@ -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, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Timer, Wrench, Toolbox, ListChecks, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus } 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 } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -17,6 +17,8 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
let dataGridComponentPromise: Promise<typeof import("@/components/grid/DataGrid.vue")> | undefined;
function loadDataGridComponent() {
@@ -59,7 +61,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, nextExecutionSummaryView, resultGridCacheKey, resultRunItems, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { databaseDisplayNameForTab, executionSummaryItems, resultGridCacheKey, resultRunItems, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { isTableDataEditable } from "@/lib/table/tableEditing";
@@ -72,8 +74,8 @@ import * as api from "@/lib/backend/api";
import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import { useTabScroll } from "@/composables/useTabScroll";
import { nextResultToolbarLayout } from "@/lib/tabs/resultToolbarLayout";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database";
@@ -137,7 +139,7 @@ const emit = defineEmits<{
editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }];
editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }];
formatError: [];
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [sql: string];
@@ -154,7 +156,7 @@ const emit = defineEmits<{
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
}>();
const { t, locale } = useI18n();
const { t } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@@ -186,7 +188,8 @@ const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const dataGridRef = ref<DataGridHandle>();
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
const resultToolbarRef = ref<HTMLElement | null>(null);
const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
const standaloneResultToolbarWidth = ref(0);
const resultTabsScrollerRef = ref<HTMLElement | null>(null);
const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
@@ -313,11 +316,7 @@ const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
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 QUERY_RESULT_AUTO_REFRESH_INTERVAL_OPTIONS = [5, 10, 30, 60, 300];
const queryResultAutoRefreshIntervalSeconds = ref(10);
const queryResultAutoRefreshEnabled = ref(false);
let queryResultAutoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const queryResultAutoRefreshLabel = computed(() => (queryResultAutoRefreshEnabled.value ? t("tabs.autoRefreshEvery", { seconds: queryResultAutoRefreshIntervalSeconds.value }) : t("tabs.autoRefresh")));
const showResultRunSelector = computed(() => resultAutoSave.value && resultRuns.value.length > 0);
watch(
() => visibleResultItems.value.map((item) => item.index).join(","),
() => {
@@ -331,80 +330,25 @@ const hasTabularResult = computed(() => {
return visibleResultItems.value.length > 0;
});
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
const resultToolbarCompact = ref(false);
const resultToolbarExpandedRequiredWidth = ref<number>();
let resultToolbarResizeObserver: ResizeObserver | undefined;
let resultToolbarLayoutFrame = 0;
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
const showStandaloneResultToolbar = computed(() => props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
const standaloneResultToolbarCompact = computed(() => standaloneResultToolbarWidth.value > 0 && standaloneResultToolbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH);
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
function updateResultToolbarLayout() {
const toolbar = resultToolbarRef.value;
const tabs = resultTabsScrollerRef.value;
if (!toolbar || !tabs) return;
const next = nextResultToolbarLayout({
resultCount: visibleResultItems.value.length,
compact: resultToolbarCompact.value,
expandedRequiredWidth: resultToolbarExpandedRequiredWidth.value,
toolbarWidth: toolbar.clientWidth,
tabsViewportWidth: tabs.clientWidth,
tabsContentWidth: tabs.scrollWidth,
});
const compactChanged = next.compact !== resultToolbarCompact.value;
resultToolbarCompact.value = next.compact;
resultToolbarExpandedRequiredWidth.value = next.expandedRequiredWidth;
if (compactChanged) {
nextTick(() => {
updateResultTabsScrollbar();
scheduleResultToolbarLayout();
function observeStandaloneResultToolbar() {
standaloneResultToolbarResizeObserver?.disconnect();
standaloneResultToolbarResizeObserver = undefined;
const toolbar = standaloneResultToolbarRef.value;
standaloneResultToolbarWidth.value = toolbar?.clientWidth ?? 0;
if (toolbar && typeof ResizeObserver !== "undefined") {
standaloneResultToolbarResizeObserver = new ResizeObserver(() => {
standaloneResultToolbarWidth.value = toolbar.clientWidth;
});
standaloneResultToolbarResizeObserver.observe(toolbar);
}
}
function scheduleResultToolbarLayout() {
if (resultToolbarLayoutFrame) return;
resultToolbarLayoutFrame = window.requestAnimationFrame(() => {
resultToolbarLayoutFrame = 0;
updateResultToolbarLayout();
});
}
function resetResultToolbarLayout() {
resultToolbarCompact.value = false;
resultToolbarExpandedRequiredWidth.value = undefined;
nextTick(() => {
updateResultTabsScrollbar();
scheduleResultToolbarLayout();
});
}
function observeResultToolbarLayout() {
resultToolbarResizeObserver?.disconnect();
resultToolbarResizeObserver = undefined;
const toolbar = resultToolbarRef.value;
const tabs = resultTabsScrollerRef.value;
if (typeof ResizeObserver !== "undefined" && toolbar && tabs) {
resultToolbarResizeObserver = new ResizeObserver(scheduleResultToolbarLayout);
resultToolbarResizeObserver.observe(toolbar);
resultToolbarResizeObserver.observe(tabs);
}
scheduleResultToolbarLayout();
}
const resultToolbarLayoutSignature = computed(() =>
JSON.stringify({
locale: locale.value,
results: visibleResultItems.value.map((item) => [item.index, item.label, item.n]),
outputView: props.activeOutputView,
canExportResultArchive: canExportResultArchive.value,
hasResult: !!props.activeTab.result,
hasTabularResult: hasTabularResult.value,
autoRefreshEnabled: queryResultAutoRefreshEnabled.value,
autoRefreshInterval: queryResultAutoRefreshIntervalSeconds.value,
}),
);
watch([resultToolbarRef, resultTabsScrollerRef], observeResultToolbarLayout, { flush: "post" });
watch(resultToolbarLayoutSignature, resetResultToolbarLayout, { flush: "post" });
watch(standaloneResultToolbarRef, observeStandaloneResultToolbar, { flush: "post" });
type MongoQueryGridChanges = {
dirtyRows: Map<number, Map<number, MongoInputValue>>;
deletedRows: Set<number>;
@@ -482,7 +426,6 @@ const resultsPaneOpen = ref(false);
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
const editorPaneSize = computed(() => (resultsPaneOpen.value ? 100 - resultsPaneSize.value : 100));
const queryRunningElapsed = ref(0);
const canAutoRefreshQueryResult = computed(() => props.activeTab.mode === "query" && props.activeOutputView === "result" && resultsPaneOpen.value && hasTabularResult.value && !props.activeTab.isExecuting);
function onResultsResized(payload: { panes: { size: number }[] }) {
const resultsPane = payload.panes[1];
@@ -524,31 +467,10 @@ watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.qu
onUnmounted(() => {
stopQueryRunningElapsedTimer();
stopQueryResultAutoRefreshTimer();
resultToolbarResizeObserver?.disconnect();
if (resultToolbarLayoutFrame) window.cancelAnimationFrame(resultToolbarLayoutFrame);
standaloneResultToolbarResizeObserver?.disconnect();
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
});
watch(() => props.activeTab.id, stopQueryResultAutoRefresh);
watch(
() => [props.activeOutputView, resultsPaneOpen.value] as const,
([outputView, paneOpen]) => {
if (outputView !== "result" || !paneOpen) stopQueryResultAutoRefresh();
},
);
watch(canAutoRefreshQueryResult, (canRefresh) => {
if (!queryResultAutoRefreshEnabled.value) return;
if (canRefresh) restartQueryResultAutoRefreshTimer();
else stopQueryResultAutoRefreshTimer();
});
watch(activeQueryError, (message) => {
if (message && queryResultAutoRefreshEnabled.value) stopQueryResultAutoRefresh();
});
watch(
hasQueryOutput,
(hasOutput) => {
@@ -734,37 +656,6 @@ function focusSearch(): boolean {
return dataGridRef.value?.focusSearch() ?? false;
}
function stopQueryResultAutoRefreshTimer() {
clearInterval(queryResultAutoRefreshTimer);
queryResultAutoRefreshTimer = undefined;
}
function runQueryResultAutoRefreshTick() {
if (!queryResultAutoRefreshEnabled.value || !canAutoRefreshQueryResult.value) return;
refreshData();
}
function restartQueryResultAutoRefreshTimer() {
stopQueryResultAutoRefreshTimer();
if (!queryResultAutoRefreshEnabled.value || !canAutoRefreshQueryResult.value) return;
queryResultAutoRefreshTimer = setInterval(runQueryResultAutoRefreshTick, queryResultAutoRefreshIntervalSeconds.value * 1000);
}
function setQueryResultAutoRefreshInterval(seconds: number) {
queryResultAutoRefreshIntervalSeconds.value = seconds;
if (queryResultAutoRefreshEnabled.value) restartQueryResultAutoRefreshTimer();
}
function toggleQueryResultAutoRefresh() {
queryResultAutoRefreshEnabled.value = !queryResultAutoRefreshEnabled.value;
restartQueryResultAutoRefreshTimer();
}
function stopQueryResultAutoRefresh() {
queryResultAutoRefreshEnabled.value = false;
stopQueryResultAutoRefreshTimer();
}
function refreshData(): boolean {
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
@@ -802,10 +693,6 @@ async function exportResultArchive() {
}
}
function toggleExecutionSummary() {
emit("update:activeOutputView", nextExecutionSummaryView(props.activeOutputView, canShowResultOutput.value));
}
async function removeResultRun(runId: string) {
const removedActiveRun = props.activeTab.activeResultRunId === runId;
const removed = await queryStore.removeResultRun(props.activeTab.id, runId);
@@ -891,18 +778,13 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</Pane>
<Pane v-if="resultsPaneOpen" class="min-h-0" :size="resultsPaneSize" :min-size="20">
<div class="h-full flex flex-col">
<div v-if="hasQueryOutput" ref="resultToolbarRef" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
<div class="flex shrink-0 items-center gap-1">
<Button size="sm" :variant="activeOutputView === 'result' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" :disabled="!hasTabularResult && !activeTab.isExecuting" @click="emit('update:activeOutputView', 'result')">
{{ t("tabs.tableData") }}
</Button>
</div>
<div v-if="hasQueryOutput" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
<Button
v-if="activeTab.mode === 'query' && activeTab.result"
variant="ghost"
size="icon"
class="h-6 w-7 shrink-0 text-muted-foreground hover:text-foreground"
:class="{ 'text-primary': resultAutoSave }"
class="h-6 w-7 shrink-0"
:class="resultAutoSave ? 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
:title="resultAutoSave ? t('tabs.autoKeepResultsEnabled') : t('tabs.autoKeepResults')"
:aria-label="resultAutoSave ? t('tabs.autoKeepResultsEnabled') : t('tabs.autoKeepResults')"
:aria-pressed="resultAutoSave"
@@ -910,7 +792,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
>
<Pin class="h-3.5 w-3.5" :class="{ 'fill-current': resultAutoSave }" />
</Button>
<template v-if="resultRuns.length > 0">
<template v-if="showResultRunSelector">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
@@ -961,71 +843,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1" :data-compact="resultToolbarCompact || undefined">
<LightTooltip :text="t('tabs.executionSummary')" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'"
class="h-6 text-xs"
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('tabs.executionSummary')"
:aria-label="t('tabs.executionSummary')"
:disabled="!hasExecutionSummary"
@click="toggleExecutionSummary"
>
<ListChecks class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("tabs.executionSummary") }}</span>
</Button>
</LightTooltip>
<LightTooltip :text="t('chart.title')" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeOutputView === 'chart' ? 'secondary' : 'ghost'"
class="h-6 text-xs"
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('chart.title')"
:aria-label="t('chart.title')"
:disabled="!hasNumericData"
@click="emit('update:activeOutputView', 'chart')"
>
<BarChart3 class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("chart.title") }}</span>
</Button>
</LightTooltip>
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<LightTooltip :text="t('explain.title')" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'"
class="h-6 text-xs"
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('explain.title')"
:aria-label="t('explain.title')"
:disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.explainTableResult && !activeTab.explainTableError && !activeTab.isExplaining"
@click="emit('update:activeOutputView', 'explain')"
>
<GitBranch class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("explain.title") }}</span>
</Button>
</LightTooltip>
<LightTooltip v-if="canExportResultArchive" :text="t('tabs.exportResultArchive')" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
size="sm"
class="h-6 shrink-0 text-xs text-muted-foreground hover:text-foreground"
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('tabs.exportResultArchive')"
:aria-label="t('tabs.exportResultArchive')"
:aria-busy="resultArchiveExporting"
:disabled="resultArchiveExporting"
@click="exportResultArchive"
>
<Loader2 v-if="resultArchiveExporting" class="h-3.5 w-3.5 animate-spin" />
<Upload v-else class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("tabs.exportResultArchive") }}</span>
</Button>
</LightTooltip>
<Popover v-if="activeOutputView === 'result' && activeTab.result">
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -1168,72 +987,28 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</PopoverContent>
</Popover>
<div v-if="activeOutputView === 'result' && hasTabularResult" class="flex h-6 shrink-0 items-center">
<LightTooltip :text="t('grid.refresh')" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
size="sm"
class="h-6 rounded-r-none text-xs text-muted-foreground hover:text-foreground"
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('grid.refresh')"
:aria-label="t('grid.refresh')"
:aria-busy="activeTab.isExecuting"
:disabled="activeTab.isExecuting"
@click="refreshData"
>
<Loader2 v-if="activeTab.isExecuting" class="h-3.5 w-3.5 animate-spin" />
<RefreshCcw v-else class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("grid.refresh") }}</span>
</Button>
</LightTooltip>
<LightTooltip :text="queryResultAutoRefreshLabel" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 rounded-l-none border-l border-border/60 text-xs"
:class="[resultToolbarCompact ? 'w-7 gap-0 px-0' : 'px-1.5', queryResultAutoRefreshEnabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground hover:text-foreground']"
:title="queryResultAutoRefreshLabel"
:aria-label="queryResultAutoRefreshLabel"
:aria-pressed="queryResultAutoRefreshEnabled"
>
<Timer class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact" class="tabular-nums">{{ queryResultAutoRefreshEnabled ? `${queryResultAutoRefreshIntervalSeconds}s` : t("tabs.autoRefreshShort") }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuItem class="gap-2" @select="toggleQueryResultAutoRefresh">
<Check v-if="queryResultAutoRefreshEnabled" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ queryResultAutoRefreshEnabled ? t("tabs.stopAutoRefresh") : t("tabs.startAutoRefresh") }}
</DropdownMenuItem>
<DropdownMenuItem v-for="seconds in QUERY_RESULT_AUTO_REFRESH_INTERVAL_OPTIONS" :key="seconds" class="gap-2" @select="setQueryResultAutoRefreshInterval(seconds)">
<Check v-if="queryResultAutoRefreshIntervalSeconds === seconds" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
{{ t("tabs.autoRefreshEvery", { seconds }) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</LightTooltip>
</div>
<LightTooltip :text="t('editor.hideResultsPane')" :disabled="!resultToolbarCompact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
size="sm"
class="h-6 shrink-0 text-xs text-muted-foreground hover:text-foreground"
:class="resultToolbarCompact ? 'w-7 gap-0 px-0' : 'gap-1 px-2'"
:title="t('editor.hideResultsPane')"
:aria-label="t('editor.hideResultsPane')"
@click="resultsPaneOpen = false"
>
<LightTooltip :text="t('editor.hideResultsPane')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-muted-foreground hover:text-foreground" :title="t('editor.hideResultsPane')" :aria-label="t('editor.hideResultsPane')" @click="resultsPaneOpen = false">
<ChevronDown class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("editor.hideResultsPane") }}</span>
</Button>
</LightTooltip>
</div>
</div>
<div v-if="hasQueryOutput && showStandaloneResultToolbar" ref="standaloneResultToolbarRef" class="flex min-h-7 shrink-0 items-center border-b bg-muted/20">
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData" :compact="standaloneResultToolbarCompact" @select-view="emit('update:activeOutputView', $event)" />
<QueryResultToolbarActions
class="ml-auto"
:active-view="activeOutputView"
:can-show-explain="canShowExplainOutput"
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="standaloneResultToolbarCompact"
@select-explain="emit('update:activeOutputView', 'explain')"
@export-archive="exportResultArchive"
/>
</div>
<ExplainPlanViewer
v-if="activeOutputView === 'explain'"
class="flex-1 min-h-0"
@@ -1322,10 +1097,24 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:all-export-results="allResultExportSheets"
:export-file-base-name="activeTab.title"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
>
<template #result-toolbar-leading="{ compact }">
<QueryResultViewSwitcher :active-view="activeOutputView" :can-show-result="canShowResultOutput" :can-show-summary="hasExecutionSummary" :can-show-chart="hasNumericData" :compact="compact" @select-view="emit('update:activeOutputView', $event)" />
</template>
<template #result-toolbar-actions="{ compact }">
<QueryResultToolbarActions
:active-view="activeOutputView"
:can-show-explain="canShowExplainOutput"
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="compact"
@select-explain="emit('update:activeOutputView', 'explain')"
@export-archive="exportResultArchive"
/>
</template>
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
<QueryErrorActions :error-message="String(errorMessage)" :connection-id="activeTab.connectionId" @change-query-timeout="activeTab.connectionId && emit('openConnectionSettings', activeTab.connectionId, 'advanced')" @fix-with-ai="(message) => emit('fixWithAi', message)" />
</template>
@@ -1623,7 +1412,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:export-file-base-name="activeTab.title"
@update:where-input="(v: string) => (activeTab.whereInput = v)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { GitBranch, Loader2, Upload } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
type OutputView = "result" | "summary" | "explain" | "chart";
withDefaults(
defineProps<{
activeView: OutputView;
canShowExplain: boolean;
canExportArchive: boolean;
archiveExporting: boolean;
compact?: boolean;
}>(),
{ compact: false },
);
const emit = defineEmits<{
selectExplain: [];
exportArchive: [];
}>();
const { t } = useI18n();
</script>
<template>
<div data-query-result-toolbar-actions class="flex shrink-0 items-center gap-1 border-r px-1">
<LightTooltip :text="t('explain.title')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'explain' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('explain.title')"
:aria-label="t('explain.title')"
:aria-pressed="activeView === 'explain'"
:disabled="!canShowExplain"
@click="emit('selectExplain')"
>
<GitBranch class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("explain.title") }}</span>
</Button>
</LightTooltip>
<LightTooltip v-if="canExportArchive" :text="t('tabs.exportResultArchive')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
size="sm"
class="h-5 shrink-0 text-xs leading-none text-muted-foreground hover:text-foreground"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('tabs.exportResultArchive')"
:aria-label="t('tabs.exportResultArchive')"
:aria-busy="archiveExporting"
:disabled="archiveExporting"
@click="emit('exportArchive')"
>
<Loader2 v-if="archiveExporting" class="block h-3.5 w-3.5 self-center animate-spin" />
<Upload v-else class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("tabs.exportResultArchive") }}</span>
</Button>
</LightTooltip>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { BarChart3, ListChecks } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
type OutputView = "result" | "summary" | "explain" | "chart";
type PrimaryResultView = Exclude<OutputView, "explain">;
const props = withDefaults(
defineProps<{
activeView: OutputView;
canShowResult: boolean;
canShowSummary: boolean;
canShowChart: boolean;
compact?: boolean;
}>(),
{ compact: false },
);
const emit = defineEmits<{
selectView: [view: PrimaryResultView];
}>();
const { t } = useI18n();
function selectView(view: PrimaryResultView) {
if (props.activeView === view) return;
emit("selectView", view);
}
</script>
<template>
<div data-query-result-view-switcher class="flex shrink-0 items-center gap-1 px-1">
<Button size="sm" :variant="activeView === 'result' ? 'secondary' : 'ghost'" class="h-5 shrink-0 px-2 text-xs leading-none" :disabled="!canShowResult" :aria-pressed="activeView === 'result'" @click="selectView('result')">
<span class="inline-flex h-4 items-center leading-none">{{ t("tabs.tableData") }}</span>
</Button>
<LightTooltip :text="t('tabs.executionSummary')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'summary' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('tabs.executionSummary')"
:aria-label="t('tabs.executionSummary')"
:aria-pressed="activeView === 'summary'"
:disabled="!canShowSummary"
@click="selectView('summary')"
>
<ListChecks class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("tabs.executionSummary") }}</span>
</Button>
</LightTooltip>
<LightTooltip :text="t('chart.title')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'chart' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('chart.title')"
:aria-label="t('chart.title')"
:aria-pressed="activeView === 'chart'"
:disabled="!canShowChart"
@click="selectView('chart')"
>
<BarChart3 class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("chart.title") }}</span>
</Button>
</LightTooltip>
</div>
</template>