diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 163cc203c..17e962877 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -2,7 +2,7 @@ import { computed, ref, watch, nextTick } from "vue"; import type { CSSProperties } from "vue"; import { useI18n } from "vue-i18n"; -import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check, Lock, Copy, AlertTriangle, Network } from "@lucide/vue"; +import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2 } 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"; @@ -40,6 +40,10 @@ const tabDrag = useTabDrag((draggedId, targetId, position) => { }); const editingTabId = ref(null); const editingTitle = ref(""); +const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); +const fixedTabs = computed(() => queryStore.tabs.filter((tab) => tab.pinned)); +const regularTabs = computed(() => queryStore.tabs.filter((tab) => !tab.pinned)); +const hasFixedTabs = computed(() => fixedTabs.value.length > 0); const compactTabTitle = computed({ get: () => settingsStore.editorSettings.compactTabTitle, set: (checked: boolean | "indeterminate") => { @@ -80,10 +84,9 @@ function cancelRenameTab() { function getTabMenuItems(tab: QueryTab): ContextMenuItem[] { return [ { - label: t("contextMenu.compactTabTitle"), + label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"), action: toggleCompactTabTitle, - icon: Check, - iconClass: compactTabTitle.value ? "opacity-100" : "opacity-0", + icon: compactTabTitle.value ? Maximize2 : Minimize2, }, { label: t("contextMenu.renameTab"), @@ -111,7 +114,7 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] { }, { label: "", separator: true }, { - label: tab.pinned ? t("contextMenu.unpin") : t("contextMenu.pin"), + label: tab.pinned ? t("contextMenu.unfixTab") : t("contextMenu.fixTab"), action: () => queryStore.togglePinnedTab(tab.id), icon: Pin, iconClass: tab.pinned ? "fill-current" : "", @@ -148,12 +151,36 @@ function handleCancelClose() { const tabsContainerRef = ref(null); const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(tabsContainerRef); +const fixedTabsContainerRef = ref(null); +const { + hasTabOverflow: hasFixedTabOverflow, + scrollThumbLeftPercent: fixedScrollThumbLeftPercent, + scrollThumbWidthPercent: fixedScrollThumbWidthPercent, + isScrollbarDragging: isFixedScrollbarDragging, + updateScrollButtons: updateFixedScrollButtons, + onTabsWheel: onFixedTabsWheel, + startScrollbarDrag: startFixedScrollbarDrag, +} = useTabScroll(fixedTabsContainerRef); const tabScrollBehavior = ref("smooth"); +function updateAllScrollButtons() { + updateScrollButtons(); + updateFixedScrollButtons(); +} + +function activeTabScrollInline(container: HTMLElement, tabId: string | null): ScrollLogicalPosition { + if (!tabId) return "center"; + const lastRegularTab = regularTabs.value[regularTabs.value.length - 1]; + const lastFixedTab = fixedTabs.value[fixedTabs.value.length - 1]; + if (container === tabsContainerRef.value && lastRegularTab?.id === tabId) return "end"; + if (container === fixedTabsContainerRef.value && lastFixedTab?.id === tabId) return "end"; + return "center"; +} + watch( - () => queryStore.tabs.length, + () => queryStore.tabs.map((tab) => `${tab.id}:${tab.pinned ? "1" : "0"}`).join("|"), () => { - nextTick(updateScrollButtons); + nextTick(updateAllScrollButtons); }, ); @@ -161,13 +188,15 @@ watch( () => queryStore.activeTabId, () => { nextTick(() => { - const container = tabsContainerRef.value; - if (!container) return; - const activeEl = container.querySelector('[data-active-tab="true"]'); - if (activeEl) { - activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: "center" }); + for (const container of [tabsContainerRef.value, fixedTabsContainerRef.value]) { + if (!container) continue; + const activeEl = container.querySelector('[data-active-tab="true"]'); + if (activeEl) { + activeEl.scrollIntoView({ behavior: tabScrollBehavior.value, block: "nearest", inline: activeTabScrollInline(container, queryStore.activeTabId) }); + break; + } } - updateScrollButtons(); + updateAllScrollButtons(); tabScrollBehavior.value = "smooth"; }); }, @@ -184,7 +213,7 @@ watch( if (el) { el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); } - updateScrollButtons(); + updateAllScrollButtons(); }); }, ); @@ -192,7 +221,7 @@ watch( function tabColorStyle(tab: QueryTab) { const color = connectionColor(tab.connectionId); const isActive = tab.id === queryStore.activeTabId && !props.driverStoreActive; - const isClassic = settingsStore.editorSettings.appLayout === "classic"; + const isClassic = isClassicLayout.value; if (!color) { if (isClassic) { return isActive ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined; @@ -222,7 +251,9 @@ function tabIconClass(tab: QueryTab) { return "text-blue-600 dark:text-blue-400"; } -const showTabOverflowControls = computed(() => queryStore.tabs.length > 0 && hasTabOverflow.value); +const showRegularTabScrollbar = computed(() => hasTabOverflow.value); +const showFixedTabScrollbar = computed(() => hasFixedTabOverflow.value); +const showRegularTabOverflowControls = computed(() => regularTabs.value.length > 0 && hasTabOverflow.value); const openTabMenuItems = computed(() => queryStore.tabs.map((tab) => ({ @@ -234,6 +265,16 @@ const openTabMenuItems = computed(() => })), ); +const fixedTabMenuItems = computed(() => + fixedTabs.value.map((tab) => ({ + value: tab.id, + label: tabDisplayTitle(tab, t), + title: tabDisplayTitle(tab, t), + icon: tabMenuIcon(tab), + iconClass: tabIconClass(tab), + })), +); + function tabMenuIcon(tab: QueryTab) { if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2; if (tab.mode === "vector") return TableProperties; @@ -257,6 +298,15 @@ function handleTabMouseDown(event: MouseEvent, tabId: string) { tabDrag.startDrag(event, tabId); } +function handleTabDragTarget(event: MouseEvent, tab: QueryTab) { + const draggedTab = queryStore.tabs.find((item) => item.id === tabDrag.state.draggedId); + if (draggedTab && draggedTab.pinned !== tab.pinned) { + tabDrag.clearTarget(tab.id); + return; + } + tabDrag.updateTarget(event, tab.id); +} + function tabDropStyle(tabId: string) { if (!tabDrag.state.active) return {}; if (tabDrag.state.draggedId === tabId) return { opacity: 0.4 }; @@ -279,10 +329,16 @@ const tabScrollbarThumbStyle = computed(() => ({ width: `${scrollThumbWidthPercent.value}%`, })); -const tabTailDragRegionClass = computed(() => (showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); +const fixedTabScrollbarThumbStyle = computed(() => ({ + insetInlineStart: `${fixedScrollThumbLeftPercent.value}%`, + width: `${fixedScrollThumbWidthPercent.value}%`, +})); + +const tabTailDragRegionClass = computed(() => (showRegularTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); +const fixedTabTailDragRegionClass = computed(() => (showFixedTabScrollbar.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch")); const tabOverflowControlClass = computed(() => - settingsStore.editorSettings.appLayout === "classic" + isClassicLayout.value ? "h-full w-8 border-r border-border/80 dark:border-border/45 bg-background/80 text-foreground/75 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40" : "h-7 w-7 rounded-md border border-border/60 bg-background text-foreground/70 hover:border-border hover:text-foreground", ); @@ -301,124 +357,223 @@ function activateTab(tabId: string) {