diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c124ba0abb..154797f94b 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -281,7 +281,8 @@ const rightSidebarPanelStorageKeys: Partial> let lastOpenedRightSidebarPanel = RIGHT_SIDEBAR_PANEL_IDS.find((panelId) => rightSidebarPanelRefs[panelId].value); const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false"); const aiPanelReady = ref(false); -const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, sqlFilePanelWidth, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize, startSqlFilePanelResize } = usePanelResize(); +const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, sqlFilePanelWidth, tabBarWidth, tabBarCollapsed, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize, startSqlFilePanelResize, startLeftTabBarResize, startRightTabBarResize, setTabBarCollapsed } = + usePanelResize(); const aiAssistantRef = ref(null); const appSidebarRef = ref | null>(null); const appTabBarRef = ref | null>(null); @@ -864,6 +865,18 @@ const tabWorkspaceLayoutClass = computed(() => { if (settingsStore.editorSettings.tabPlacement === "right") return "flex-row-reverse"; return isVerticalTabPlacement.value ? "flex-row" : "flex-col"; }); + +function startTabBarResize(event: MouseEvent) { + if (settingsStore.editorSettings.tabPlacement === "right") { + startRightTabBarResize(event); + return; + } + startLeftTabBarResize(event); +} + +function toggleTabBarCollapsed() { + setTabBarCollapsed(!tabBarCollapsed.value); +} const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled); function openSettings(initialTab = "appearance", initialSection?: string) { @@ -3377,6 +3390,8 @@ onUnmounted(() => { :agent-driver-update-count="toolbarAgentDriverUpdateCount" :detached-drop-target="detachedDropTargetTabId !== null" :can-detach-tabs="isDesktop" + :tab-bar-width="tabBarWidth" + :tab-bar-collapsed="tabBarCollapsed" @toggle-zen-mode="toggleZenMode" @activate-driver-store="openDriverStorePage" @activate-settings-page="activateSettingsPage" @@ -3390,6 +3405,8 @@ onUnmounted(() => { @discard-all-tab-close="handleDiscardAllPendingTabClose" @cancel-tab-close="cancelPendingAppClose" @detach-tab="detachTab" + @start-resize="startTabBarResize" + @toggle-collapse="toggleTabBarCollapsed" /> (); const emit = defineEmits<{ @@ -48,6 +82,8 @@ const emit = defineEmits<{ "discard-all-tab-close": []; "cancel-tab-close": []; "detach-tab": [tab: QueryTab, position?: { x: number; y: number }]; + "start-resize": [event: MouseEvent]; + "toggle-collapse": []; }>(); const { t } = useI18n(); @@ -74,6 +110,60 @@ const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout == const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right"); const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap"); const isManualTabOrder = computed(() => settingsStore.editorSettings.tabSortMode === "manual"); +// The icon-only collapse only exists in the vertical toolbar; horizontal +// placements must ignore the persisted collapse state entirely. +const isTabBarCollapsed = computed(() => isVerticalLayout.value && !!props.tabBarCollapsed); +const tabBarStyle = computed(() => { + if (!isVerticalLayout.value) return undefined; + if (props.tabBarCollapsed) return { width: "3.5rem", flex: "0 0 3.5rem" }; + const width = props.tabBarWidth ?? 240; + return { width: `${width}px`, flex: `0 0 ${width}px` }; +}); +const tabBarCollapseIcon = computed(() => { + const isLeft = settingsStore.editorSettings.tabPlacement === "left"; + if (props.tabBarCollapsed) return isLeft ? ChevronsRight : ChevronsLeft; + return isLeft ? ChevronsLeft : ChevronsRight; +}); +const tabBarCollapseLabel = computed(() => t(props.tabBarCollapsed ? "tabs.expandTabBar" : "tabs.collapseTabBar")); +const verticalTabToolbarButtonClass = "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; +const tabGroupItems = computed(() => [ + { value: "none", label: t("settings.tabGroupNone") }, + { value: "database-type", label: t("settings.tabGroupDatabaseType") }, + { value: "connection", label: t("settings.tabGroupConnection") }, +]); +const tabSortItems = computed(() => [ + { value: "manual", label: t("settings.tabSortManual") }, + { value: "created-asc", label: t("settings.tabSortCreated") }, + { value: "title-asc", label: t("settings.tabSortTitle") }, +]); +const tabPlacementItems = computed(() => [ + { value: "top", label: t("settings.tabPlacementTop") }, + { value: "bottom", label: t("settings.tabPlacementBottom") }, + { value: "left", label: t("settings.tabPlacementLeft") }, + { value: "right", label: t("settings.tabPlacementRight") }, +]); + +type TabPreferencePatch = Partial>; + +async function persistTabPreferences(partial: TabPreferencePatch) { + try { + await settingsStore.updateEditorSettingsAndPersist(partial); + } catch (error) { + toast(t("tabs.settingsSaveFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } +} + +function updateTabGroupMode(value: string) { + if (value === "none" || value === "database-type" || value === "connection") void persistTabPreferences({ tabGroupMode: value }); +} + +function updateTabSortMode(value: string) { + if (value === "manual" || value === "created-asc" || value === "title-asc") void persistTabPreferences({ tabSortMode: value }); +} + +function updateTabPlacement(value: string) { + if (value === "top" || value === "bottom" || value === "left" || value === "right") void persistTabPreferences({ tabPlacement: value }); +} function tabGroupKey(tab: QueryTab) { const connection = connectionStore.getConfig(tab.connectionId); @@ -107,17 +197,144 @@ function sortDisplayedTabs(tabs: QueryTab[]) { const fixedTabs = computed(() => sortDisplayedTabs(queryStore.tabs.filter((tab) => tab.pinned))); const regularTabs = computed(() => sortDisplayedTabs(queryStore.tabs.filter((tab) => !tab.pinned))); const displayedTabs = computed(() => [...fixedTabs.value, ...regularTabs.value]); +const collapsedTabGroups = ref>(new Set()); +const tabGroupPalette = ["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"]; +const tabGroupEditorOpen = ref(false); +const editingTabGroupKey = ref(""); +const editingTabGroupDefaultLabel = ref(""); +const editingTabGroupName = ref(""); +const editingTabGroupColor = ref(""); +const editingTabGroupFallbackColor = ref(tabGroupPalette[0]!); function isGroupStart(tabs: QueryTab[], index: number) { if (settingsStore.editorSettings.tabGroupMode === "none") return false; return index === 0 || tabGroupKey(tabs[index - 1]!) !== tabGroupKey(tabs[index]!); } -function tabGroupLabel(tab: QueryTab) { +function isGroupEnd(tabs: QueryTab[], index: number) { + if (settingsStore.editorSettings.tabGroupMode === "none") return false; + return index === tabs.length - 1 || tabGroupKey(tabs[index + 1]!) !== tabGroupKey(tabs[index]!); +} + +function tabGroupDefaultLabel(tab: QueryTab) { const connection = connectionStore.getConfig(tab.connectionId); if (settingsStore.editorSettings.tabGroupMode === "connection") return connection?.name || tab.connectionId; return connection?.driver_label || connection?.driver_profile || connection?.db_type || tab.connectionId; } + +function tabGroupCustomizationKey(tab: QueryTab) { + return `${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`; +} + +function tabGroupCustomization(tab: QueryTab) { + return settingsStore.editorSettings.tabGroupCustomizations[tabGroupCustomizationKey(tab)]; +} + +function tabGroupLabel(tab: QueryTab) { + return tabGroupCustomization(tab)?.name || tabGroupDefaultLabel(tab); +} + +function tabGroupId(tab: QueryTab) { + return `${tab.pinned ? "fixed" : "regular"}:${tabGroupKey(tab)}`; +} + +function isTabGroupCollapsed(tab: QueryTab) { + if (settingsStore.editorSettings.tabGroupMode === "none" || tabSearchQuery.value.trim()) return false; + return collapsedTabGroups.value.has(tabGroupId(tab)); +} + +function isTabGroupActive(tab: QueryTab) { + if (props.driverStoreActive || props.settingsPageActive) return false; + return tabsInDisplayGroup(tab).some((item) => item.id === queryStore.activeTabId); +} + +function toggleTabGroup(tab: QueryTab) { + 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(updateAllScrollButtons); +} + +function expandTabGroupForTab(tabId: string | null) { + if (!tabId || settingsStore.editorSettings.tabGroupMode === "none") return; + const tab = queryStore.tabs.find((item) => item.id === tabId); + if (!tab) return; + const groupId = tabGroupId(tab); + if (!collapsedTabGroups.value.has(groupId)) return; + const next = new Set(collapsedTabGroups.value); + next.delete(groupId); + collapsedTabGroups.value = next; +} + +function defaultTabGroupColor(tab: QueryTab) { + const connectionGroupColor = settingsStore.editorSettings.tabGroupMode === "connection" ? connectionColor(tab.connectionId) : undefined; + let hash = 0; + for (const character of tabGroupKey(tab)) hash = (hash * 31 + character.codePointAt(0)!) | 0; + return connectionGroupColor || tabGroupPalette[Math.abs(hash) % tabGroupPalette.length]!; +} + +function resolvedTabGroupColor(tab: QueryTab) { + return tabGroupCustomization(tab)?.color || defaultTabGroupColor(tab); +} + +function groupColorStyle(color: string): CSSProperties { + return { + "--tab-group-color": color, + "--tab-group-soft": hexToRgba(color, 0.12), + "--tab-group-rail": hexToRgba(color, 0.3), + } as CSSProperties; +} + +function tabGroupStyle(tab: QueryTab): CSSProperties { + return groupColorStyle(resolvedTabGroupColor(tab)); +} + +const tabGroupEditorPreviewStyle = computed(() => groupColorStyle(editingTabGroupColor.value || editingTabGroupFallbackColor.value)); +const editingTabGroupHasCustomization = computed(() => { + const customization = settingsStore.editorSettings.tabGroupCustomizations[editingTabGroupKey.value]; + return !!(customization?.name || customization?.color); +}); + +function openTabGroupEditor(tab: QueryTab) { + const customization = tabGroupCustomization(tab); + editingTabGroupKey.value = tabGroupCustomizationKey(tab); + editingTabGroupDefaultLabel.value = tabGroupDefaultLabel(tab); + editingTabGroupName.value = customization?.name || ""; + editingTabGroupColor.value = customization?.color || ""; + editingTabGroupFallbackColor.value = defaultTabGroupColor(tab); + tabGroupEditorOpen.value = true; + nextTick(() => document.querySelector("[data-tab-group-name-input]")?.focus()); +} + +async function persistTabGroupCustomization(name: string, color: string) { + if (!editingTabGroupKey.value) return false; + const customizations = { ...settingsStore.editorSettings.tabGroupCustomizations }; + const normalizedName = name.trim(); + if (normalizedName || color) customizations[editingTabGroupKey.value] = { ...(normalizedName ? { name: normalizedName } : {}), ...(color ? { color } : {}) }; + else delete customizations[editingTabGroupKey.value]; + try { + await settingsStore.updateEditorSettingsAndPersist({ tabGroupCustomizations: customizations }); + return true; + } catch (error) { + toast(t("tabs.settingsSaveFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + return false; + } +} + +async function saveTabGroupCustomization() { + if (await persistTabGroupCustomization(editingTabGroupName.value, editingTabGroupColor.value)) tabGroupEditorOpen.value = false; +} + +function updateCustomTabGroupColor(event: Event) { + editingTabGroupColor.value = (event.target as HTMLInputElement).value; +} + +async function resetTabGroupCustomization(tab?: QueryTab) { + if (tab) editingTabGroupKey.value = tabGroupCustomizationKey(tab); + if ((await persistTabGroupCustomization("", "")) && !tab) tabGroupEditorOpen.value = false; +} const hasFixedTabs = computed(() => fixedTabs.value.length > 0); const regularSurfaceCount = computed(() => regularTabs.value.length + (props.driverStoreOpen ? 1 : 0) + (props.settingsPageOpen ? 1 : 0)); const closeConfirmDirtyCount = computed(() => queryStore.closeConfirmDirtyTabIds.length); @@ -241,6 +458,23 @@ function tabTitleStyle(tab: QueryTab): CSSProperties | undefined { } type SpecialRegularSurface = "driverStore" | "settings"; +const pendingGroupCloseSpecialSurface = ref(null); + +function restoreSpecialRegularSurface(surface: SpecialRegularSurface | null) { + if (!surface) return; + // App.vue activates the query surface when activeTabId changes, so restore + // the special surface after that watcher has completed its current flush. + nextTick(() => { + if (surface === "settings" && props.settingsPageOpen) emit("activate-settings-page"); + if (surface === "driverStore" && props.driverStoreOpen) emit("activate-driver-store"); + }); +} + +function finishSpecialSurfacePreservingGroupClose() { + const surface = pendingGroupCloseSpecialSurface.value; + pendingGroupCloseSpecialSurface.value = null; + restoreSpecialRegularSurface(surface); +} function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) { if (keep !== "driverStore" && props.driverStoreOpen) emit("close-driver-store"); @@ -252,6 +486,23 @@ function closeOtherRegularTabsFromTab(tab: QueryTab) { closeSpecialRegularSurfaces(); } +function tabsInDisplayGroup(tab: QueryTab) { + if (settingsStore.editorSettings.tabGroupMode === "none") return []; + const groupKey = tabGroupKey(tab); + const displayedGroup = tab.pinned ? fixedTabs.value : regularTabs.value; + return displayedGroup.filter((item) => tabGroupKey(item) === groupKey); +} + +function closeTabGroup(tab: QueryTab) { + const tabsToClose = tabsInDisplayGroup(tab).map((item) => item.id); + if (tabsToClose.length === 0) return; + const activeSpecialSurface: SpecialRegularSurface | null = props.settingsPageActive ? "settings" : props.driverStoreActive ? "driverStore" : null; + pendingGroupCloseSpecialSurface.value = activeSpecialSurface; + const finalActiveTabId = queryStore.activeTabId && !tabsToClose.includes(queryStore.activeTabId) ? queryStore.activeTabId : (queryStore.tabs.find((item) => !tabsToClose.includes(item.id))?.id ?? null); + queryStore.closeTabsByIds(tabsToClose, finalActiveTabId, finishSpecialSurfacePreservingGroupClose); + restoreSpecialRegularSurface(activeSpecialSurface); +} + function tabsToRightInGroup(tab: QueryTab) { const groupedTabs = tab.pinned ? fixedTabs.value : regularTabs.value; const targetIndex = groupedTabs.findIndex((item) => item.id === tab.id); @@ -368,6 +619,70 @@ function getSpecialRegularTabMenuItems(surface: SpecialRegularSurface): ContextM ]; } +function getTabPreferenceMenuItems(): ContextMenuItem[] { + return [ + { + label: t("settings.tabPlacement"), + icon: PanelTop, + children: tabPlacementItems.value.map((item) => ({ + label: item.label, + checked: item.value === settingsStore.editorSettings.tabPlacement, + action: () => updateTabPlacement(item.value), + })), + }, + { + label: t("settings.tabGroup"), + icon: ListFilter, + children: tabGroupItems.value.map((item) => ({ + label: item.label, + checked: item.value === settingsStore.editorSettings.tabGroupMode, + action: () => updateTabGroupMode(item.value), + })), + }, + { + label: t("settings.tabSort"), + icon: ArrowDownUp, + children: tabSortItems.value.map((item) => ({ + label: item.label, + checked: item.value === settingsStore.editorSettings.tabSortMode, + action: () => updateTabSortMode(item.value), + })), + }, + ]; +} + +function getTabGroupMenuItems(tab: QueryTab): ContextMenuItem[] { + const customization = tabGroupCustomization(tab); + return [ + { + label: t("contextMenu.editTabGroup"), + action: () => openTabGroupEditor(tab), + icon: Pencil, + }, + { + label: t("contextMenu.resetTabGroup"), + action: () => resetTabGroupCustomization(tab), + icon: RotateCcw, + visible: !!(customization?.name || customization?.color), + }, + { label: "", separator: true }, + ...getTabPreferenceMenuItems(), + { label: "", separator: true }, + { + label: t("contextMenu.closeTabGroup"), + action: () => closeTabGroup(tab), + icon: X, + variant: "destructive", + }, + ]; +} + +function openTabGroupContextMenu(event: MouseEvent, open: (event: MouseEvent) => void) { + event.preventDefault(); + document.getSelection()?.removeAllRanges(); + open(event); +} + function getTabMenuItems(tab: QueryTab): ContextMenuItem[] { const closeCurrentLabel = tab.pinned ? t("contextMenu.closeFixedTab") : t("contextMenu.closeTab"); const closeOtherLabel = tab.pinned ? t("contextMenu.closeOtherFixedTabs") : hasFixedTabs.value ? t("contextMenu.closeOtherRegularTabs") : t("contextMenu.closeOtherTabs"); @@ -419,6 +734,8 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] { visible: !!activeTabSidebarTarget(tab), }, { label: "", separator: true }, + ...getTabPreferenceMenuItems(), + { label: "", separator: true }, { label: tab.pinned ? t("contextMenu.unfixTab") : t("contextMenu.fixTab"), action: () => queryStore.togglePinnedTab(tab.id), @@ -427,6 +744,12 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] { }, { label: "", separator: true }, { label: closeCurrentLabel, action: () => queryStore.closeTab(tab.id), icon: X }, + { + label: t("contextMenu.closeTabGroup"), + action: () => closeTabGroup(tab), + visible: settingsStore.editorSettings.tabGroupMode !== "none", + icon: X, + }, { label: closeOtherLabel, action: closeOtherAction, @@ -474,8 +797,15 @@ function handleDiscardAllAndClose() { emit("discard-all-tab-close"); } -function handleCancelClose() { +function dismissCloseConfirm() { + const specialSurface = pendingGroupCloseSpecialSurface.value; + pendingGroupCloseSpecialSurface.value = null; queryStore.cancelClosePendingTab(); + restoreSpecialRegularSurface(specialSurface); +} + +function handleCancelClose() { + dismissCloseConfirm(); emit("cancel-tab-close"); } @@ -522,8 +852,19 @@ watch( ); watch( - () => queryStore.activeTabId, + () => settingsStore.editorSettings.tabGroupMode, () => { + collapsedTabGroups.value = new Set(); + tabGroupEditorOpen.value = false; + nextTick(updateAllScrollButtons); + }, +); + +watch( + () => queryStore.activeTabId, + (tabId) => { + expandTabGroupForTab(tabId); + restoreSpecialRegularSurface(pendingGroupCloseSpecialSurface.value); nextTick(() => { if (!isWrapLayout.value) { for (const container of [tabsContainerRef.value, fixedTabsContainerRef.value]) { @@ -581,6 +922,16 @@ function tabColorStyle(tab: QueryTab) { const color = connectionColor(tab.connectionId); const isActive = tab.id === queryStore.activeTabId && !props.driverStoreActive && !props.settingsPageActive; const isClassic = isClassicLayout.value; + if (isVerticalLayout.value) { + if (!isActive) return undefined; + return color + ? { + "--app-tab-background": hexToRgba(color, 0.12), + } + : { + "--app-tab-background": "var(--accent)", + }; + } if (!color) { if (isClassic) { return isActive ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined; @@ -609,9 +960,15 @@ function tabColorStyle(tab: QueryTab) { function specialTabActiveStyle(active: boolean | undefined): CSSProperties | undefined { if (!active) return undefined; + if (isVerticalLayout.value) return { backgroundColor: "var(--accent)" }; return isClassicLayout.value ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : { borderColor: "var(--ring)" }; } +const tabTooltipSide = computed(() => { + if (!isVerticalLayout.value) return "bottom" as const; + return settingsStore.editorSettings.tabPlacement === "left" ? ("right" as const) : ("left" as const); +}); + function tabIconClass(tab: QueryTab) { if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400"; if (tab.mode === "mq") return ""; @@ -640,6 +997,14 @@ const showRegularTabOverflowControls = computed(() => regularTabs.value.length > const regularTabOverflowOpen = ref(false); const fixedTabOverflowOpen = ref(false); const tabSearchQuery = ref(""); + +watch( + () => props.tabBarCollapsed, + (collapsed) => { + if (collapsed) tabSearchQuery.value = ""; + }, +); + const filteredOpenTabs = computed(() => { const query = tabSearchQuery.value.trim().toLocaleLowerCase(); if (!query) return displayedTabs.value; @@ -668,6 +1033,7 @@ const tabBarClass = computed(() => [ : `w-full bg-background ${settingsStore.editorSettings.tabPlacement === "bottom" ? "border-t" : "border-b"}`, hasFixedTabs.value ? "flex-col" : "", isClassicLayout.value && hasFixedTabs.value && !isVerticalLayout.value ? "border-b" : "", + isVerticalLayout.value && props.tabBarCollapsed ? "vertical-tab-layout--collapsed" : "", ]); const regularTabRowClass = computed(() => (isVerticalLayout.value ? ["min-h-0 flex-1 flex-col items-stretch"] : [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""])); @@ -803,10 +1169,38 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul