fix(desktop): stabilize tab overflow scrolling

This commit is contained in:
t8y2 2026-06-09 18:58:06 +08:00
parent 426b7911bc
commit 9278630fd2
4 changed files with 280 additions and 200 deletions

View File

@ -6,8 +6,6 @@ import {
X,
Pin,
ChevronDown,
ChevronLeft,
ChevronRight,
Table2,
Code2,
TableProperties,
@ -24,12 +22,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useTabDrag } from "@/composables/useTabDrag";
import {
connectionColor,
shouldShowTabOverflowControls,
tabDisplayTitle,
tabTooltipLines,
} from "@/lib/tabPresentation";
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
import { hexToRgba } from "@/lib/color";
import type { QueryTab } from "@/types/database";
@ -127,8 +120,15 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
}
const tabsContainerRef = ref<HTMLElement | null>(null);
const { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel } =
useTabScroll(tabsContainerRef);
const {
hasTabOverflow,
scrollThumbLeftPercent,
scrollThumbWidthPercent,
isScrollbarDragging,
updateScrollButtons,
onTabsWheel,
startScrollbarDrag,
} = useTabScroll(tabsContainerRef);
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
watch(
@ -204,14 +204,7 @@ function tabIconClass(tab: QueryTab) {
return "text-blue-600 dark:text-blue-400";
}
const showTabOverflowControls = computed(() =>
shouldShowTabOverflowControls(
queryStore.tabs.length,
hasTabOverflow.value,
canScrollLeft.value,
canScrollRight.value,
),
);
const showTabOverflowControls = computed(() => queryStore.tabs.length > 0 && hasTabOverflow.value);
const openTabMenuItems = computed(() =>
queryStore.tabs.map((tab) => ({
@ -261,6 +254,11 @@ const tabsContainerStyle = computed<CSSProperties>(() => ({
WebkitOverflowScrolling: "touch",
}));
const tabScrollbarThumbStyle = computed<CSSProperties>(() => ({
insetInlineStart: `${scrollThumbLeftPercent.value}%`,
width: `${scrollThumbWidthPercent.value}%`,
}));
const tabTailDragRegionClass = computed(() =>
showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
);
@ -296,159 +294,150 @@ function activateTab(tabId: string) {
: 'h-10 items-center bg-background px-2'
"
>
<button
v-if="showTabOverflowControls"
type="button"
class="z-30 shrink-0 inline-flex items-center justify-center"
:class="[tabOverflowControlClass, canScrollLeft ? '' : 'opacity-40']"
:aria-label="t('tabs.scrollLeft')"
:title="t('tabs.scrollLeft')"
:disabled="!canScrollLeft"
@click="scrollTabs('left')"
>
<ChevronLeft class="h-4 w-4 stroke-[2.5]" />
</button>
<div
ref="tabsContainerRef"
class="flex-1 flex items-center overflow-x-auto min-w-0"
:class="settingsStore.editorSettings.appLayout === 'classic' ? '' : 'gap-1.5'"
:style="tabsContainerStyle"
@scroll="updateScrollButtons"
@wheel="onTabsWheel"
>
<CustomContextMenu
v-for="tab in queryStore.tabs"
:key="tab.id"
:items="getTabMenuItems(tab)"
v-slot="{ onContextMenu }"
>
<div :class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''" @contextmenu="onContextMenu">
<Tooltip>
<TooltipTrigger as-child>
<div
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 dark:border-border/45',
tab.id === queryStore.activeTabId && !showDriverStore
? 'bg-background text-foreground font-medium'
: 'text-foreground/70 hover:text-foreground/90',
]
: [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-7 rounded-md border',
tab.id === queryStore.activeTabId && !showDriverStore
? 'text-foreground font-medium'
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
]
"
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
@click="handleTabClick(tab)"
@dblclick.stop="startRenameTab(tab)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@mousedown="handleTabMouseDown($event, tab.id)"
@mouseenter="tabDrag.updateTarget($event, tab.id)"
@mousemove="tabDrag.updateTarget($event, tab.id)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<span class="shrink-0" :class="tabIconClass(tab)">
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
v-if="editingTabId === tab.id"
v-model="editingTitle"
:data-tab-title-input="tab.id"
:aria-label="t('contextMenu.renameTab')"
class="h-5 min-w-0 flex-1 rounded border border-ring bg-background px-1.5 text-xs font-normal text-foreground outline-none"
@click.stop
@mousedown.stop
@keydown.enter.prevent="commitRenameTab(tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(tab)"
/>
<span v-else class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
<Tooltip>
<TooltipTrigger as-child>
<button
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
@click.stop="queryStore.togglePinnedTab(tab.id)"
>
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
</Tooltip>
<button
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
@click.stop="queryStore.closeTab(tab.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
<!-- Driver Store Tab -->
<div class="relative h-full min-w-0 flex-1">
<div
v-if="showDriverStore"
data-driver-store-tab
class="group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium']
: ['h-7 rounded-md border text-foreground font-medium', 'border-ring']
"
:style="
settingsStore.editorSettings.appLayout === 'classic' ? { boxShadow: '0 1px 0 0 var(--color-background)' } : {}
"
@click="emit('toggle-driver-store')"
v-if="showTabOverflowControls"
class="app-tab-scrollbar"
:class="{ 'app-tab-scrollbar--dragging': isScrollbarDragging }"
@pointerdown="startScrollbarDrag"
>
<span class="shrink-0 text-amber-600 dark:text-amber-400">
<Package class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">{{ t("toolbar.driverManager") }}</span>
<span
v-if="(agentDriverUpdateCount ?? 0) > 0"
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
:aria-label="t('toolbar.updatableDriverCount')"
>
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
</span>
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
<X class="h-3 w-3" />
</button>
<div class="app-tab-scrollbar__thumb" :style="tabScrollbarThumbStyle" />
</div>
<div
ref="tabsContainerRef"
class="app-tab-scroll flex min-w-0 flex-1 items-center overflow-x-auto"
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : 'h-10 gap-1.5 py-1.5'"
:style="tabsContainerStyle"
@scroll="updateScrollButtons"
@wheel="onTabsWheel"
>
<CustomContextMenu
v-for="tab in queryStore.tabs"
:key="tab.id"
:items="getTabMenuItems(tab)"
v-slot="{ onContextMenu }"
>
<div
:class="settingsStore.editorSettings.appLayout === 'classic' ? 'h-full' : ''"
@contextmenu="onContextMenu"
>
<Tooltip>
<TooltipTrigger as-child>
<div
class="group flex items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap select-none"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-full border-r border-border/80 font-medium dark:border-border/45',
tab.id === queryStore.activeTabId && !showDriverStore
? 'bg-background text-foreground'
: 'text-foreground/70 hover:text-foreground/90',
]
: [
compactTabTitle ? 'min-w-24' : 'min-w-38',
'h-7 rounded-md border',
tab.id === queryStore.activeTabId && !showDriverStore
? 'text-foreground font-medium'
: 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90',
]
"
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore"
@click="handleTabClick(tab)"
@dblclick.stop="startRenameTab(tab)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@mousedown="handleTabMouseDown($event, tab.id)"
@mouseenter="tabDrag.updateTarget($event, tab.id)"
@mousemove="tabDrag.updateTarget($event, tab.id)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<span class="shrink-0" :class="tabIconClass(tab)">
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
v-if="editingTabId === tab.id"
v-model="editingTitle"
:data-tab-title-input="tab.id"
:aria-label="t('contextMenu.renameTab')"
class="h-5 min-w-0 flex-1 rounded border border-ring bg-background px-1.5 text-xs font-normal text-foreground outline-none"
@click.stop
@mousedown.stop
@keydown.enter.prevent="commitRenameTab(tab)"
@keydown.escape.prevent="cancelRenameTab"
@blur="commitRenameTab(tab)"
/>
<span v-else class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
<Tooltip>
<TooltipTrigger as-child>
<button
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
@click.stop="queryStore.togglePinnedTab(tab.id)"
>
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }}</TooltipContent>
</Tooltip>
<button
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
@click.stop="queryStore.closeTab(tab.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</div>
</CustomContextMenu>
<!-- Driver Store Tab -->
<div
v-if="showDriverStore"
data-driver-store-tab
class="group flex min-w-38 items-center gap-1 px-2 text-xs cursor-pointer transition-colors whitespace-nowrap"
:class="
settingsStore.editorSettings.appLayout === 'classic'
? ['h-full border-r border-border/80 dark:border-border/45 bg-background text-foreground font-medium']
: ['h-7 rounded-md border text-foreground font-medium', 'border-ring']
"
:style="
settingsStore.editorSettings.appLayout === 'classic'
? { boxShadow: '0 1px 0 0 var(--color-background)' }
: {}
"
@click="emit('toggle-driver-store')"
>
<span class="shrink-0 text-amber-600 dark:text-amber-400">
<Package class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">{{ t("toolbar.driverManager") }}</span>
<span
v-if="(agentDriverUpdateCount ?? 0) > 0"
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white"
:aria-label="t('toolbar.updatableDriverCount')"
>
{{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }}
</span>
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="emit('close-driver-store')">
<X class="h-3 w-3" />
</button>
</div>
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
<div v-if="showTabOverflowControls" class="relative z-30 flex shrink-0 items-center">
<button
v-if="showTabOverflowControls"
type="button"
class="inline-flex shrink-0 items-center justify-center"
:class="[tabOverflowControlClass, canScrollRight ? '' : 'opacity-40']"
:aria-label="t('tabs.scrollRight')"
:title="t('tabs.scrollRight')"
:disabled="!canScrollRight"
@click="scrollTabs('right')"
>
<ChevronRight class="h-4 w-4 stroke-[2.5]" />
</button>
<LightDropdown
:model-value="queryStore.activeTabId || ''"
:items="openTabMenuItems"
@ -471,3 +460,47 @@ function activateTab(tabId: string) {
</div>
</div>
</template>
<style scoped>
.app-tab-scroll::-webkit-scrollbar {
display: none;
}
.app-tab-scrollbar {
position: absolute;
inset-inline: 0;
top: 0;
z-index: 20;
height: 6px;
cursor: pointer;
touch-action: none;
}
.app-tab-scrollbar::before {
content: "";
position: absolute;
inset-inline: 0;
top: 0;
height: 2px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 8%, transparent);
}
.app-tab-scrollbar__thumb {
position: absolute;
top: 0;
height: 2px;
min-width: 20px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 30%, transparent);
transition:
height 120ms ease,
background-color 120ms ease;
}
.app-tab-scrollbar:hover .app-tab-scrollbar__thumb,
.app-tab-scrollbar--dragging .app-tab-scrollbar__thumb {
height: 5px;
background: color-mix(in oklch, var(--foreground) 52%, transparent);
}
</style>

View File

@ -2,23 +2,31 @@ import { onBeforeUnmount, ref, watch, type Ref } from "vue";
export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
const hasTabOverflow = ref(false);
const canScrollLeft = ref(false);
const canScrollRight = ref(false);
const scrollThumbLeftPercent = ref(0);
const scrollThumbWidthPercent = ref(100);
const isScrollbarDragging = ref(false);
let resizeObserver: ResizeObserver | null = null;
let updateFrame = 0;
let dragState: {
trackRect: DOMRect;
thumbOffsetPx: number;
} | null = null;
function updateScrollButtons() {
const el = tabsContainerRef.value;
if (!el) {
hasTabOverflow.value = false;
canScrollLeft.value = false;
canScrollRight.value = false;
scrollThumbLeftPercent.value = 0;
scrollThumbWidthPercent.value = 100;
return;
}
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
hasTabOverflow.value = maxScrollLeft > 1;
canScrollLeft.value = el.scrollLeft > 1;
canScrollRight.value = el.scrollLeft < maxScrollLeft - 1;
const rawThumbWidth = el.scrollWidth > 0 ? (el.clientWidth / el.scrollWidth) * 100 : 100;
const thumbWidth = Math.min(100, Math.max(8, rawThumbWidth));
const thumbTravel = Math.max(0, 100 - thumbWidth);
scrollThumbWidthPercent.value = thumbWidth;
scrollThumbLeftPercent.value = maxScrollLeft > 0 ? (el.scrollLeft / maxScrollLeft) * thumbTravel : 0;
}
function scheduleScrollButtonUpdate() {
@ -34,15 +42,6 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
}
}
function scrollTabs(direction: "left" | "right") {
const el = tabsContainerRef.value;
if (!el) return;
const scrollAmount = Math.max(160, el.clientWidth * 0.65);
el.scrollBy({ left: direction === "left" ? -scrollAmount : scrollAmount, behavior: "smooth" });
scheduleScrollButtonUpdate();
window.setTimeout(updateScrollButtons, 260);
}
function onTabsWheel(event: WheelEvent) {
const el = tabsContainerRef.value;
if (!el) return;
@ -60,6 +59,63 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
}
}
function applyScrollbarDrag(clientX: number) {
const el = tabsContainerRef.value;
if (!el || !dragState) return;
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
if (maxScrollLeft <= 1) return;
const thumbWidthPx = dragState.trackRect.width * (scrollThumbWidthPercent.value / 100);
const maxThumbLeftPx = Math.max(1, dragState.trackRect.width - thumbWidthPx);
const thumbLeftPx = Math.min(
maxThumbLeftPx,
Math.max(0, clientX - dragState.trackRect.left - dragState.thumbOffsetPx),
);
el.scrollLeft = (thumbLeftPx / maxThumbLeftPx) * maxScrollLeft;
updateScrollButtons();
}
function onScrollbarPointerMove(event: PointerEvent) {
if (!dragState) return;
event.preventDefault();
applyScrollbarDrag(event.clientX);
}
function stopScrollbarDrag() {
if (!dragState) return;
dragState = null;
isScrollbarDragging.value = false;
window.removeEventListener("pointermove", onScrollbarPointerMove, true);
window.removeEventListener("pointerup", stopScrollbarDrag, true);
window.removeEventListener("pointercancel", stopScrollbarDrag, true);
document.body.style.userSelect = "";
}
function startScrollbarDrag(event: PointerEvent) {
const el = tabsContainerRef.value;
const track = event.currentTarget as HTMLElement | null;
if (!el || !track || !hasTabOverflow.value) return;
const trackRect = track.getBoundingClientRect();
const thumbLeftPx = trackRect.width * (scrollThumbLeftPercent.value / 100);
const thumbWidthPx = trackRect.width * (scrollThumbWidthPercent.value / 100);
const pointerX = event.clientX - trackRect.left;
const pointerInsideThumb = pointerX >= thumbLeftPx && pointerX <= thumbLeftPx + thumbWidthPx;
dragState = {
trackRect,
thumbOffsetPx: pointerInsideThumb ? pointerX - thumbLeftPx : thumbWidthPx / 2,
};
isScrollbarDragging.value = true;
document.body.style.userSelect = "none";
window.addEventListener("pointermove", onScrollbarPointerMove, true);
window.addEventListener("pointerup", stopScrollbarDrag, true);
window.addEventListener("pointercancel", stopScrollbarDrag, true);
event.preventDefault();
applyScrollbarDrag(event.clientX);
}
watch(
tabsContainerRef,
(el) => {
@ -76,10 +132,19 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
onBeforeUnmount(() => {
resizeObserver?.disconnect();
stopScrollbarDrag();
if (updateFrame && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(updateFrame);
}
});
return { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel };
return {
hasTabOverflow,
scrollThumbLeftPercent,
scrollThumbWidthPercent,
isScrollbarDragging,
updateScrollButtons,
onTabsWheel,
startScrollbarDrag,
};
}

View File

@ -97,15 +97,6 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v
return lines;
}
export function shouldShowTabOverflowControls(
tabCount: number,
hasTabOverflow: boolean,
canScrollLeft: boolean,
canScrollRight: boolean,
): boolean {
return tabCount > 0 && (hasTabOverflow || canScrollLeft || canScrollRight);
}
export function tabularResultItems(
results: QueryResult[] | undefined,
): { result: QueryResult; index: number; n: number }[] {

View File

@ -3,7 +3,6 @@ import { test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import {
executionSummaryItems,
shouldShowTabOverflowControls,
tabDisplayTitle,
tabularResultItems,
} from "../../apps/desktop/src/lib/tabPresentation.ts";
@ -62,14 +61,6 @@ function result(columns: string[]): QueryResult {
};
}
test("tab overflow controls only show when there are hidden tabs to reach", () => {
assert.equal(shouldShowTabOverflowControls(0, true, true, true), false);
assert.equal(shouldShowTabOverflowControls(3, false, false, false), false);
assert.equal(shouldShowTabOverflowControls(3, true, false, false), true);
assert.equal(shouldShowTabOverflowControls(3, false, true, false), true);
assert.equal(shouldShowTabOverflowControls(3, false, false, true), true);
});
test("query tab display title uses custom title when present", () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());