From 9278630fd2671c37e4a406b8a779f980bae1ce9b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 9 Jun 2026 18:58:06 +0800 Subject: [PATCH] fix(desktop): stabilize tab overflow scrolling --- .../src/components/layout/AppTabBar.vue | 365 ++++++++++-------- apps/desktop/src/composables/useTabScroll.ts | 97 ++++- apps/desktop/src/lib/tabPresentation.ts | 9 - packages/app-tests/tabPresentation.test.ts | 9 - 4 files changed, 280 insertions(+), 200 deletions(-) diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index d911ab72e..eca3edff0 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -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(null); -const { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel } = - useTabScroll(tabsContainerRef); +const { + hasTabOverflow, + scrollThumbLeftPercent, + scrollThumbWidthPercent, + isScrollbarDragging, + updateScrollButtons, + onTabsWheel, + startScrollbarDrag, +} = useTabScroll(tabsContainerRef); const tabScrollBehavior = ref("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(() => ({ WebkitOverflowScrolling: "touch", })); +const tabScrollbarThumbStyle = computed(() => ({ + 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' " > - -
- -
- - -
- - - - - - - - - {{ tabDisplayTitle(tab, t) }} - - - - - {{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }} - - -
-
- - - -
-
-
- - +
- - - - {{ t("toolbar.driverManager") }} - - {{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }} - - +
+
+
+ +
+ + +
+ + + + + + + + + {{ tabDisplayTitle(tab, t) }} + + + + + {{ tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin") }} + + +
+
+ + + +
+
+
+ + +
+ + + + {{ t("toolbar.driverManager") }} + + {{ (agentDriverUpdateCount ?? 0) > 99 ? "99+" : agentDriverUpdateCount }} + + +
+
-
-
+ + diff --git a/apps/desktop/src/composables/useTabScroll.ts b/apps/desktop/src/composables/useTabScroll.ts index 1e4e75c78..f3248193c 100644 --- a/apps/desktop/src/composables/useTabScroll.ts +++ b/apps/desktop/src/composables/useTabScroll.ts @@ -2,23 +2,31 @@ import { onBeforeUnmount, ref, watch, type Ref } from "vue"; export function useTabScroll(tabsContainerRef: Ref) { 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) { } } - 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) { } } + 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) { 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, + }; } diff --git a/apps/desktop/src/lib/tabPresentation.ts b/apps/desktop/src/lib/tabPresentation.ts index a3bd57160..ba0432e49 100644 --- a/apps/desktop/src/lib/tabPresentation.ts +++ b/apps/desktop/src/lib/tabPresentation.ts @@ -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 }[] { diff --git a/packages/app-tests/tabPresentation.test.ts b/packages/app-tests/tabPresentation.test.ts index d07b77834..cd9577f5e 100644 --- a/packages/app-tests/tabPresentation.test.ts +++ b/packages/app-tests/tabPresentation.test.ts @@ -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());