fix(desktop): add scrollable query result tabs (#986)

Thanks for the contribution!
This commit is contained in:
Scars 2026-06-10 15:13:17 +08:00 committed by GitHub
parent 8a458f1432
commit cb6c09e346
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 163 additions and 76 deletions

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Loader2, Search, Square, Bot, Table2, GitBranch, BarChart3, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, ListChecks } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
@ -45,6 +46,7 @@ import { isTableDataEditable } from "@/lib/tableEditing";
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { formatShortcut } from "@/lib/shortcutRegistry";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { useTabScroll } from "@/composables/useTabScroll";
import type { QueryTab, ConnectionConfig } from "@/types/database";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
@ -135,6 +137,7 @@ const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const dataGridRef = ref<DataGridHandle>();
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
const resultTabsScrollerRef = ref<HTMLElement | null>(null);
const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
@ -176,6 +179,27 @@ const modRKeys = computed(() =>
.map((key) => (key === "Cmd" ? "⌘" : key)),
);
const {
hasTabOverflow: hasResultTabOverflow,
scrollThumbLeftPercent: resultTabsThumbLeftPercent,
scrollThumbWidthPercent: resultTabsThumbWidthPercent,
isScrollbarDragging: isResultTabsScrollbarDragging,
updateScrollButtons: updateResultTabsScrollbar,
onTabsWheel: onResultTabsWheel,
startScrollbarDrag: startResultTabsScrollbarDrag,
} = useTabScroll(resultTabsScrollerRef);
const resultTabsScrollerStyle: CSSProperties = {
msOverflowStyle: "none",
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
};
const resultTabsScrollbarThumbStyle = computed<CSSProperties>(() => ({
insetInlineStart: `${resultTabsThumbLeftPercent.value}%`,
width: `${resultTabsThumbWidthPercent.value}%`,
}));
const hasNumericData = computed(() => {
const r = props.activeTab.result;
if (!r || r.rows.length === 0) return false;
@ -189,6 +213,12 @@ const activeQueryError = computed(() => {
});
const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
watch(
() => tabularResults.value.map((item) => item.index).join(","),
() => {
nextTick(updateResultTabsScrollbar);
},
);
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
const hasTabularResult = computed(() => {
@ -432,87 +462,98 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</Pane>
<Pane v-if="resultsPaneOpen" :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div v-if="hasQueryOutput" class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1 overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch">
<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>
<template v-if="tabularResults.length > 1">
<span class="mx-1 h-4 w-px bg-border" />
<Button
v-for="item in tabularResults"
:key="item.index"
size="sm"
:variant="activeOutputView === 'result' && activeTab.activeResultIndex === item.index ? 'default' : 'ghost'"
class="h-6 px-2 text-xs shrink-0"
@click="
queryStore.setActiveResultIndex(activeTab.id, item.index);
emit('update:activeOutputView', 'result');
"
>
{{ t("tabs.resultN", { n: item.n }) }}
<div v-if="hasQueryOutput" 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>
</template>
<Button size="sm" :variant="activeOutputView === 'summary' ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs gap-1" :disabled="!hasExecutionSummary" @click="emit('update:activeOutputView', 'summary')">
<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>
<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>
<Popover v-if="activeOutputView === 'result' && activeTab.result">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="ml-auto h-6 w-7 shrink-0 text-foreground hover:bg-accent"
:class="{
'bg-accent text-foreground': dataGridRef?.nullColumnsHidden || dataGridRef?.multiRowTranspose,
}"
:title="t('grid.viewOptions')"
:aria-label="t('grid.viewOptions')"
>
<Wrench class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<template v-if="tabularResults.length > 1">
<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="hasResultTabOverflow" class="result-tab-scrollbar" :class="{ 'result-tab-scrollbar--dragging': isResultTabsScrollbarDragging }" @pointerdown="startResultTabsScrollbarDrag">
<div class="result-tab-scrollbar__thumb" :style="resultTabsScrollbarThumbStyle" />
</div>
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
<label class="flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-xs hover:bg-accent">
<span class="min-w-0 flex items-center gap-1.5 font-medium">
{{ t("grid.transposeMultiRowToggle") }}
<span class="text-muted-foreground">
{{ dataGridRef?.multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow") }}
<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">
<Button
v-for="item in tabularResults"
:key="item.index"
size="sm"
:variant="activeOutputView === 'result' && activeTab.activeResultIndex === item.index ? 'default' : 'ghost'"
class="h-6 px-2 text-xs shrink-0"
@click="
queryStore.setActiveResultIndex(activeTab.id, item.index);
emit('update:activeOutputView', 'result');
"
>
{{ t("tabs.resultN", { n: item.n }) }}
</Button>
</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="emit('update:activeOutputView', 'summary')">
<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>
<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>
<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"
:class="{
'bg-accent text-foreground': dataGridRef?.nullColumnsHidden || dataGridRef?.multiRowTranspose,
}"
:title="t('grid.viewOptions')"
:aria-label="t('grid.viewOptions')"
>
<Wrench class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
<label class="flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-xs hover:bg-accent">
<span class="min-w-0 flex items-center gap-1.5 font-medium">
{{ t("grid.transposeMultiRowToggle") }}
<span class="text-muted-foreground">
{{ dataGridRef?.multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow") }}
</span>
</span>
<Switch size="sm" :model-value="!!dataGridRef?.multiRowTranspose" :aria-label="t('grid.transposeMultiRow')" @update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)" />
</label>
</LightTooltip>
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
<span class="min-w-0 flex items-center gap-1.5 font-medium">
{{ t("grid.hideNullColumns") }}
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
</span>
<Switch size="sm" :model-value="!!dataGridRef?.multiRowTranspose" :aria-label="t('grid.transposeMultiRow')" @update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)" />
</label>
</LightTooltip>
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
<span class="min-w-0 flex items-center gap-1 font-medium">
{{ t("grid.hideNullColumns") }}
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
</span>
</label>
</PopoverContent>
</Popover>
<Button v-if="activeOutputView === 'result' && hasTabularResult" variant="ghost" size="sm" class="h-6 shrink-0 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>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :class="{ 'ml-auto': activeOutputView !== 'result' || !hasTabularResult }" @click="resultsPaneOpen = false">
<ChevronDown class="h-3.5 w-3.5" />
{{ t("editor.hideResultsPane") }}
</Button>
</PopoverContent>
</Popover>
<Button v-if="activeOutputView === 'result' && hasTabularResult" variant="ghost" size="sm" class="h-6 shrink-0 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>
<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>
</div>
</div>
<ExplainPlanViewer v-if="activeOutputView === 'explain'" class="flex-1 min-h-0" :plan="activeTab.explainPlan" :error="activeTab.explainError" :loading="activeTab.isExplaining" :source-sql="activeTab.lastExplainedSql" :explain-sql="activeTab.explainSql" />
@ -821,3 +862,49 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</template>
</div>
</template>
<style scoped>
.result-tab-scroll::-webkit-scrollbar {
display: none;
}
.result-tab-scrollbar {
position: absolute;
inset-inline: 0.5rem;
bottom: 2px;
z-index: 20;
height: 8px;
cursor: pointer;
touch-action: none;
}
.result-tab-scrollbar::before {
content: "";
position: absolute;
inset-inline: 0;
top: 3px;
height: 2px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 10%, transparent);
}
.result-tab-scrollbar__thumb {
position: absolute;
top: 2px;
height: 4px;
min-width: 20px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 38%, transparent);
transition:
height 120ms ease,
background-color 120ms ease,
top 120ms ease;
}
.result-tab-scrollbar:hover .result-tab-scrollbar__thumb,
.result-tab-scrollbar--dragging .result-tab-scrollbar__thumb {
top: 1px;
height: 6px;
background: color-mix(in oklch, var(--foreground) 58%, transparent);
}
</style>