From 05b719e7a680c83c3a654e57cc6c4144d509a647 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 8 Sep 2026 17:11:30 +0800 Subject: [PATCH] feat(editor): show pinned tabs in separate row with per-row scroll --- .../components/layout/EditorGroupTabBar.vue | 595 +++++++++++------- .../src/components/layout/TabModeIcon.vue | 10 +- .../EditorGroupTabBar.groups.spec.ts | 36 +- .../__tests__/EditorGroupTabBar.spec.ts | 9 + .../src/components/layout/appTabBar.css | 498 ++++++++++++++- .../src/components/objects/ObjectBrowser.vue | 6 +- apps/desktop/src/composables/useTabScroll.ts | 9 +- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../__tests__/tabs/tabPresentation.spec.ts | 10 +- apps/desktop/src/lib/tabs/tabPresentation.ts | 28 +- 18 files changed, 958 insertions(+), 252 deletions(-) diff --git a/apps/desktop/src/components/layout/EditorGroupTabBar.vue b/apps/desktop/src/components/layout/EditorGroupTabBar.vue index 7faef7ad94..3c8b7f799e 100644 --- a/apps/desktop/src/components/layout/EditorGroupTabBar.vue +++ b/apps/desktop/src/components/layout/EditorGroupTabBar.vue @@ -30,39 +30,7 @@ const TAB_DRAG_HORIZONTAL_THRESHOLD = 24; import { computed, nextTick, onUnmounted, ref, watch } from "vue"; import type { CSSProperties } from "vue"; import { useI18n } from "vue-i18n"; -import { - AlertTriangle, - ArrowDown, - ArrowDownUp, - ArrowRight, - CalendarClock, - ChevronDown, - ChevronsLeft, - ChevronsRight, - Code2, - Copy, - Database, - Gauge, - KeyRound, - ListFilter, - Maximize2, - Minimize2, - Network, - Package, - PanelTop, - Pencil, - PencilRuler, - Pin, - RotateCcw, - RotateCw, - Search, - Settings, - ShieldCheck, - Table2, - TableProperties, - X, - Activity, -} from "@lucide/vue"; +import { ArrowDown, ArrowDownUp, ArrowRight, ChevronDown, ChevronsDownUp, ChevronsLeft, ChevronsRight, ChevronsUpDown, Copy, ListFilter, Maximize2, Minimize2, Package, PanelTop, Pencil, Pin, RotateCcw, RotateCw, Search, Settings, X } from "@lucide/vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import LightDropdown from "@/components/ui/LightDropdown.vue"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; @@ -70,8 +38,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue"; +import TabModeIcon from "@/components/layout/TabModeIcon.vue"; import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -83,7 +51,7 @@ import { hexToRgba } from "@/lib/common/color"; import { copyToClipboard } from "@/lib/common/clipboard"; import { parseTabDragPayload, serializeTabDragPayload } from "@/lib/tabs/tabDrag"; import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu"; -import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation"; +import { connectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDisplayTitle, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation"; import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget"; import "./appTabBar.css"; import type { QueryTab } from "@/types/database"; @@ -123,6 +91,10 @@ const connectionStore = useConnectionStore(); const { toast } = useToast(); const tabsContainerRef = ref(null); const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(tabsContainerRef); +const fixedTabsRowRef = ref(null); +const regularTabsRowRef = ref(null); +const fixedTabsScroll = useTabScroll(fixedTabsRowRef); +const regularTabsScroll = useTabScroll(regularTabsRowRef); const editingTabId = ref(null); const editingTitle = ref(""); // Drag suppression must survive pointerup: the browser fires click *after* @@ -204,13 +176,16 @@ function getSpecialPageTabMenuItems(surface: "settings" | "driverStore"): Contex ]; } +const hasHorizontalFixedRows = computed(() => !isVerticalLayout.value && props.tabs.some((tab) => tab.pinned) && (props.tabs.some((tab) => !tab.pinned) || showSpecialPageTabs.value)); const tabBarClass = computed(() => [ + isClassicLayout.value ? "classic-tab-layout" : "separated-tab-layout", isVerticalLayout.value ? `vertical-tab-layout h-full w-60 flex-col bg-background ${settingsStore.editorSettings.tabPlacement === "right" ? "border-l" : "border-r"}` : isClassicLayout.value ? "bg-muted" : `bg-background ${settingsStore.editorSettings.tabPlacement === "bottom" ? "border-t" : "border-b"}`, isVerticalLayout.value && props.tabBarCollapsed ? "vertical-tab-layout--collapsed" : "", + hasHorizontalFixedRows.value ? "horizontal-fixed-tabs" : "", ]); const tabsContainerStyle = computed(() => ({ msOverflowStyle: "none", @@ -225,6 +200,7 @@ const tabScrollbarThumbStyle = computed(() => ({ // Overflow search lists this group's tabs (mirrors the legacy AppTabBar // overflow popover, scoped to the group that owns the strip). const tabOverflowOpen = ref(false); +const tabLayoutRevision = ref(0); // The overflow popover's "search opened tabs" query is scoped to the popover // list only. It must not reach the strip: with a shared query, typing a term // with no match would empty the always-visible top tab bar while the active @@ -248,8 +224,61 @@ watch(tabOverflowOpen, (open) => { } }); -const showOverflowControl = computed(() => props.tabs.length > 0 && hasTabOverflow.value && !isWrapLayout.value && !isVerticalLayout.value); -const tabTailDragRegionClass = computed(() => (showOverflowControl.value || isWrapLayout.value || isVerticalLayout.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); +function setTabSectionRef(key: string, element: Element | null) { + const row = element instanceof HTMLElement ? element : null; + if (key === "fixed") { + fixedTabsRowRef.value = row; + } else { + regularTabsRowRef.value = row; + } +} + +function tabSectionScroll(key: string) { + return key === "fixed" ? fixedTabsScroll : regularTabsScroll; +} + +function tabSectionThumbStyle(key: string): CSSProperties { + const scroll = tabSectionScroll(key); + return { + insetInlineStart: `${scroll.scrollThumbLeftPercent.value}%`, + width: `${scroll.scrollThumbWidthPercent.value}%`, + }; +} + +function tabSectionHasOverflow(key: string): boolean { + const row = key === "fixed" ? fixedTabsRowRef.value : regularTabsRowRef.value; + return !!row && row.scrollWidth - row.clientWidth > 1; +} + +function hasHorizontalRowOverflow(): boolean { + return tabSectionHasOverflow("fixed") || tabSectionHasOverflow("regular"); +} + +function onHorizontalTabWheel(event: WheelEvent) { + // A trackpad can emit vertical and horizontal deltas together. Only let a + // clearly horizontal gesture reach the row's native horizontal scroller. + if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) { + event.preventDefault(); + event.stopPropagation(); + } +} + +function revealActiveTabAfterLayout() { + const activeTab = tabsContainerRef.value?.querySelector('[data-active-tab="true"]'); + if (!activeTab) return; + activeTab.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: "nearest" }); +} + +const showOverflowControl = computed(() => { + void tabLayoutRevision.value; + const hasOverflow = hasHorizontalFixedRows.value ? fixedTabsScroll.hasTabOverflow.value || regularTabsScroll.hasTabOverflow.value || hasHorizontalRowOverflow() : hasTabOverflow.value; + return props.tabs.length > 0 && hasOverflow && !isWrapLayout.value && !isVerticalLayout.value; +}); +const tabTailDragRegionClass = computed(() => { + if (isWrapLayout.value || isVerticalLayout.value) return "w-0 flex-none self-stretch"; + if (showOverflowControl.value) return "tab-tail-overflow-spacer flex-none self-stretch"; + return "min-w-8 flex-1 self-stretch"; +}); watch( () => props.tabBarCollapsed, @@ -392,6 +421,7 @@ const sortedPinnedTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab const sortedRegularTabs = computed(() => sortDisplayedTabs(props.tabs.filter((tab) => !tab.pinned))); const collapsedTabGroups = ref>(new Set()); +const pendingTabScrollRestore = ref<{ fixed: number; regular: number } | null>(null); const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"]; const tabGroupEditorOpen = ref(false); const editingTabGroupKey = ref(""); @@ -445,13 +475,55 @@ function isTabGroupActive(tab: QueryTab) { return section.some((item) => tabGroupKey(item) === groupKey && isTabActive(item)); } +function preserveTabScrollPosition() { + pendingTabScrollRestore.value = { + fixed: fixedTabsRowRef.value?.scrollLeft ?? 0, + regular: regularTabsRowRef.value?.scrollLeft ?? 0, + }; +} + +function restoreTabScrollPosition(position: { fixed: number; regular: number }) { + if (fixedTabsRowRef.value) fixedTabsRowRef.value.scrollLeft = position.fixed; + if (regularTabsRowRef.value) regularTabsRowRef.value.scrollLeft = position.regular; +} + +function refreshHorizontalTabOverflow() { + updateScrollButtons(); + fixedTabsScroll.updateScrollButtons(); + regularTabsScroll.updateScrollButtons(); + tabLayoutRevision.value += 1; +} + +function handleTabGroupTransitionEnd(event: TransitionEvent) { + if (event.propertyName !== "max-width") return; + refreshHorizontalTabOverflow(); +} + function toggleTabGroup(tab: QueryTab) { + preserveTabScrollPosition(); const groupId = tabGroupId(tab); const next = new Set(collapsedTabGroups.value); if (next.has(groupId)) next.delete(groupId); else next.add(groupId); collapsedTabGroups.value = next; - nextTick(updateScrollButtons); + nextTick(refreshHorizontalTabOverflow); +} + +function tabGroupIdsInPane() { + if (settingsStore.editorSettings.tabGroupMode === "none") return []; + return [...new Set(props.tabs.map((tab) => tabGroupId(tab)))]; +} + +function collapseAllTabGroups() { + preserveTabScrollPosition(); + collapsedTabGroups.value = new Set(tabGroupIdsInPane()); + nextTick(refreshHorizontalTabOverflow); +} + +function expandAllTabGroups() { + preserveTabScrollPosition(); + collapsedTabGroups.value = new Set(); + nextTick(refreshHorizontalTabOverflow); } function expandTabGroupForTab(tabId: string | null) { @@ -603,6 +675,19 @@ function getTabGroupMenuItems(tab: QueryTab): ContextMenuItem[] { visible: !!(customization?.name || customization?.color), }, { label: "", separator: true }, + { + label: t("contextMenu.collapseAll"), + action: collapseAllTabGroups, + icon: ChevronsDownUp, + visible: settingsStore.editorSettings.tabGroupMode !== "none", + }, + { + label: t("contextMenu.expandAll"), + action: expandAllTabGroups, + icon: ChevronsUpDown, + visible: settingsStore.editorSettings.tabGroupMode !== "none", + }, + { label: "", separator: true }, ...getTabPreferenceMenuItems(), { label: "", separator: true }, { @@ -625,7 +710,7 @@ type StripEntry = { kind: "header"; key: string; tab: QueryTab; pinned: boolean; /** * Flattens the strip's two sections (pinned, then regular) into render * entries: a group header before each semantic cluster, then that cluster's - * pills. Collapsed clusters contribute no tab entries, only their header. + * pills. Collapsed pills remain mounted so their visibility can animate. */ function tabMatchesSearch(tab: QueryTab, query: string) { const title = tabDisplayTitle(tab, t).toLocaleLowerCase(); @@ -646,28 +731,30 @@ const filteredRegularTabs = computed(() => { return query ? sortedRegularTabs.value.filter((tab) => tabMatchesSearch(tab, query)) : sortedRegularTabs.value; }); -const stripEntries = computed(() => { +function buildStripEntries(section: QueryTab[], pinned: boolean): StripEntry[] { const entries: StripEntry[] = []; const grouping = settingsStore.editorSettings.tabGroupMode !== "none"; - for (const [section, pinned] of [ - [filteredPinnedTabs.value, true], - [filteredRegularTabs.value, false], - ] as const) { - section.forEach((tab, index) => { - const first = grouping && (index === 0 || tabGroupKey(section[index - 1]!) !== tabGroupKey(tab)); - const last = grouping && (index === section.length - 1 || tabGroupKey(section[index + 1]!) !== tabGroupKey(tab)); - if (first) { - const groupKey = tabGroupKey(tab); - entries.push({ kind: "header", key: `header:${tab.id}`, tab, pinned, count: section.filter((item) => tabGroupKey(item) === groupKey).length }); - } - // Searching must reveal collapsed clusters, matching the upstream bar. - if (grouping && !tabSearchQuery.value.trim() && isTabGroupCollapsed(tab)) { - return; - } - entries.push({ kind: "tab", key: tab.id, tab, groupFirst: first, groupLast: last, grouping }); - }); - } + section.forEach((tab, index) => { + const first = grouping && (index === 0 || tabGroupKey(section[index - 1]!) !== tabGroupKey(tab)); + const last = grouping && (index === section.length - 1 || tabGroupKey(section[index + 1]!) !== tabGroupKey(tab)); + if (first) { + const groupKey = tabGroupKey(tab); + entries.push({ kind: "header", key: `header:${tab.id}`, tab, pinned, count: section.filter((item) => tabGroupKey(item) === groupKey).length }); + } + entries.push({ kind: "tab", key: tab.id, tab, groupFirst: first, groupLast: last, grouping }); + }); return entries; +} + +const pinnedStripEntries = computed(() => buildStripEntries(filteredPinnedTabs.value, true)); +const regularStripEntries = computed(() => buildStripEntries(filteredRegularTabs.value, false)); +const stripSections = computed(() => { + const fixed = { key: "fixed", pinned: true, entries: pinnedStripEntries.value }; + const regular = { key: "regular", pinned: false, entries: regularStripEntries.value }; + if (isVerticalLayout.value) return [fixed, regular]; + if (props.tabs.some((tab) => tab.pinned) && !props.tabs.some((tab) => !tab.pinned) && !showSpecialPageTabs.value) return [fixed]; + if (!hasHorizontalFixedRows.value) return [regular]; + return settingsStore.editorSettings.tabPlacement === "top" ? [regular, fixed] : [fixed, regular]; }); function tabColorStyle(tab: QueryTab): CSSProperties | undefined { @@ -1145,11 +1232,47 @@ watch( }, ); +watch(isVerticalLayout, (vertical) => { + if (!vertical) tabSearchQuery.value = ""; +}); + watch( - () => props.tabs.map((tab) => `${tab.id}:${tab.pinned ? "1" : "0"}`).join("|"), + () => [ + props.tabs.map((tab) => `${tab.id}:${tab.pinned ? "1" : "0"}:${tab.title}:${tab.mode}`).join("|"), + props.specialPageTabs?.settingsOpen, + props.specialPageTabs?.driverStoreOpen, + settingsStore.editorSettings.tabLayout, + settingsStore.editorSettings.tabGroupMode, + compactTabTitle.value, + Array.from(collapsedTabGroups.value).sort().join("|"), + ], () => { - nextTick(updateScrollButtons); + // Tab content can change without changing the scroll container's size. + // Re-measure after Vue has committed the new pills to avoid stale overflow controls. + const scrollPositionToRestore = pendingTabScrollRestore.value; + pendingTabScrollRestore.value = null; + nextTick(() => + requestAnimationFrame(() => { + tabLayoutRevision.value++; + updateScrollButtons(); + fixedTabsScroll.updateScrollButtons(); + regularTabsScroll.updateScrollButtons(); + if (scrollPositionToRestore) { + nextTick(() => + requestAnimationFrame(() => { + restoreTabScrollPosition(scrollPositionToRestore); + updateScrollButtons(); + fixedTabsScroll.updateScrollButtons(); + regularTabsScroll.updateScrollButtons(); + }), + ); + } else { + nextTick(() => requestAnimationFrame(revealActiveTabAfterLayout)); + } + }), + ); }, + { flush: "post" }, ); watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?.driverStoreActive, () => settingsStore.editorSettings.tabPlacement, () => props.tabBarCollapsed], () => { @@ -1164,7 +1287,15 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?