feat(tabs): enhance result toolbar layout and labels

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-10 15:54:01 +08:00
1 parent abee51d625
commit ea8b3f9800
10 files changed
+376 -81

No files matched your search

@@ -72,6 +72,7 @@ import { buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
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";
@@ -152,7 +153,7 @@ const emit = defineEmits<{
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
}>();
const { t } = useI18n();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@@ -184,6 +185,7 @@ 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 resultTabsScrollerRef = ref<HTMLElement | null>(null);
const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
@@ -323,6 +325,80 @@ 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;
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 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" });
type MongoQueryGridChanges = {
dirtyRows: Map<number, Map<number, MongoInputValue>>;
deletedRows: Set<number>;
@@ -424,6 +500,8 @@ watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.qu
onUnmounted(() => {
stopQueryRunningElapsedTimer();
stopQueryResultAutoRefreshTimer();
resultToolbarResizeObserver?.disconnect();
if (resultToolbarLayoutFrame) window.cancelAnimationFrame(resultToolbarLayoutFrame);
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
});
@@ -786,7 +864,7 @@ 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" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
<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") }}
@@ -856,25 +934,70 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</div>
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Button size="sm" :variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasExecutionSummary" @click="toggleExecutionSummary">
<ListChecks class="h-3.5 w-3.5" />
{{ t("tabs.executionSummary") }}
</Button>
<Button size="sm" :variant="activeOutputView === 'chart' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasNumericData" @click="emit('update:activeOutputView', 'chart')">
<BarChart3 class="h-3.5 w-3.5" />
{{ t("chart.title") }}
</Button>
<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" />
<Button size="sm" :variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining" @click="emit('update:activeOutputView', 'explain')">
<GitBranch class="h-3.5 w-3.5" />
{{ t("explain.title") }}
</Button>
<Button v-if="canExportResultArchive" variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :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" />
{{ t("tabs.exportResultArchive") }}
</Button>
<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.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">
<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')">
@@ -1001,44 +1124,68 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</PopoverContent>
</Popover>
<div v-if="activeOutputView === 'result' && hasTabularResult" class="flex h-6 shrink-0 items-center">
<Button variant="ghost" size="sm" class="h-6 rounded-r-none gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :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" />
{{ t("grid.refresh") }}
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 rounded-l-none border-l border-border/60 px-1.5 text-xs"
:class="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 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 :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>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" @click="resultsPaneOpen = false">
<ChevronDown class="h-3.5 w-3.5" />
{{ t("editor.hideResultsPane") }}
</Button>
<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"
>
<ChevronDown class="h-3.5 w-3.5" />
<span v-if="!resultToolbarCompact">{{ t("editor.hideResultsPane") }}</span>
</Button>
</LightTooltip>
</div>
</div>