diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a6136c3f79..2202bafa33 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -7497,6 +7497,7 @@ onUnmounted(() => { {{ t("settings.tabGroupDatabaseType") }} {{ t("settings.tabGroupDatabase") }} {{ t("settings.tabGroupConnection") }} + {{ t("settings.tabGroupSidebar") }} diff --git a/apps/desktop/src/components/layout/EditorGroupTabBar.vue b/apps/desktop/src/components/layout/EditorGroupTabBar.vue index a598b36f28..4135ab886a 100644 --- a/apps/desktop/src/components/layout/EditorGroupTabBar.vue +++ b/apps/desktop/src/components/layout/EditorGroupTabBar.vue @@ -70,6 +70,7 @@ import { Input } from "@/components/ui/input"; import { appTabActiveBackground, appTabActiveIndicator } from "@/lib/tabs/tabPresentation"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import ConnectionIcon from "@/components/icons/ConnectionIcon.vue"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue"; import TabModeIcon from "@/components/layout/TabModeIcon.vue"; @@ -82,12 +83,15 @@ import { useTabScroll } from "@/composables/useTabScroll"; import { useToast } from "@/composables/useToast"; import { hexToRgba } from "@/lib/common/color"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { parseTabDragPayload, serializeTabDragPayload } from "@/lib/tabs/tabDrag"; import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu"; import { tabConnectionColor, dirtyTabTitleStyle, tabColorStyle as sharedTabColorStyle, tabDatabaseIconType, tabDisplayTitle, tabDisplayTitles, tabIconClass, tabTooltipLines } from "@/lib/tabs/tabPresentation"; import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget"; import "./appTabBar.css"; import type { QueryTab } from "@/types/database"; +import { buildConnectionDatabaseTabGroupStripEntries, type TabGroupTreeGuide } from "@/lib/tabs/tabGroupTree"; +import TabGroupTreeGuides from "./TabGroupTreeGuides.vue"; const props = defineProps<{ groupId: string; @@ -176,6 +180,7 @@ const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.edi // 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 showTabTreeGuides = computed(() => isVerticalLayout.value && !isTabBarCollapsed.value && settingsStore.editorSettings.tabGroupMode === "sidebar"); // Outer [data-workspace-tab-navigation] / [data-special-page-navigation] owns // the shared vertical rail width; fill that rail so nested pane bars stay in // sync while the resize handle updates the outer panel (issue #9977). @@ -353,6 +358,7 @@ const tabGroupItems = computed(() => [ { value: "database-type", label: t("settings.tabGroupDatabaseType") }, { value: "database", label: t("settings.tabGroupDatabase") }, { value: "connection", label: t("settings.tabGroupConnection") }, + { value: "sidebar", label: t("settings.tabGroupSidebar") }, ]); const tabSortItems = computed(() => [ { value: "manual", label: t("settings.tabSortManual") }, @@ -377,7 +383,7 @@ async function persistTabPreferences(partial: TabPreferencePatch) { } function updateTabGroupMode(value: string) { - if (value === "none" || value === "database-type" || value === "database" || value === "connection") void persistTabPreferences({ tabGroupMode: value }); + if (value === "none" || value === "database-type" || value === "database" || value === "connection" || value === "sidebar") void persistTabPreferences({ tabGroupMode: value }); } function updateTabSortMode(value: string) { @@ -399,6 +405,7 @@ function databaseTabGroupKey(tab: QueryTab) { function tabGroupKey(tab: QueryTab) { const connection = connectionStore.getConfig(tab.connectionId); + if (settingsStore.editorSettings.tabGroupMode === "sidebar") return tab.connectionId; if (settingsStore.editorSettings.tabGroupMode === "connection") return tab.connectionId; if (settingsStore.editorSettings.tabGroupMode === "database") return databaseTabGroupKey(tab); return connection?.driver_profile || connection?.db_type || tab.connectionId || "unknown"; @@ -436,6 +443,12 @@ function databaseTabGroupBaseLabel(tab: QueryTab) { return [tab.database, ...(tab.catalog ? [tab.catalog] : [])].join(" · "); } +function hierarchyDatabaseLabel(tab: QueryTab) { + const connection = connectionStore.getConfig(tab.connectionId); + if (connection?.db_type === "redis" && tab.database !== "") return redisDatabaseLabel(tab.database, connection.redis_database_aliases); + return tab.database ? databaseTabGroupBaseLabel(tab) : tab.catalog || tabConnectionLabel(tab); +} + const databaseTabGroupIdentityDisplaysByLabel = computed(() => { const identitiesByLabel = new Map>(); for (const tab of props.tabs) { @@ -497,6 +510,7 @@ const editingTabGroupFallbackColor = ref(tabGroupPalette[0]!); function tabGroupDefaultLabel(tab: QueryTab) { const connection = connectionStore.getConfig(tab.connectionId); + if (settingsStore.editorSettings.tabGroupMode === "sidebar") return tabConnectionLabel(tab); if (settingsStore.editorSettings.tabGroupMode === "connection") return connection?.name || tab.connectionId; if (settingsStore.editorSettings.tabGroupMode === "database") { const baseLabel = databaseTabGroupBaseLabel(tab); @@ -513,7 +527,8 @@ function tabGroupDefaultLabel(tab: QueryTab) { } function tabGroupCustomizationKey(tab: QueryTab) { - return `${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`; + const mode = settingsStore.editorSettings.tabGroupMode === "sidebar" ? "connection" : settingsStore.editorSettings.tabGroupMode; + return `${mode}:${tabGroupKey(tab)}`; } function tabGroupCustomization(tab: QueryTab) { @@ -529,18 +544,27 @@ function tabGroupId(tab: QueryTab) { return `${tab.pinned ? "fixed" : "regular"}:${settingsStore.editorSettings.tabGroupMode}:${tabGroupKey(tab)}`; } -function isTabGroupCollapsed(tab: QueryTab) { - if (settingsStore.editorSettings.tabGroupMode === "none") return false; - return collapsedTabGroups.value.has(tabGroupId(tab)); +function isGroupCollapsed(groupId: string) { + return settingsStore.editorSettings.tabGroupMode !== "none" && collapsedTabGroups.value.has(groupId); } -const activeTabGroupId = computed(() => { +const activeTabGroupIds = computed(() => { const activeTab = props.tabs.find((item) => isTabActive(item)); - return activeTab ? tabGroupId(activeTab) : null; + if (!activeTab) return new Set(); + if (settingsStore.editorSettings.tabGroupMode !== "sidebar") return new Set([tabGroupId(activeTab)]); + const ids = new Set(); + for (const section of stripSections.value) { + const entry = section.entries.find((item) => item.kind === "tab" && item.tab.id === activeTab.id); + if (entry?.kind === "tab") { + entry.ancestorIds.forEach((id) => ids.add(id)); + ids.add(entry.groupId); + } + } + return ids; }); -function isTabGroupActive(tab: QueryTab) { - return activeTabGroupId.value === tabGroupId(tab); +function isGroupActive(groupId: string) { + return activeTabGroupIds.value.has(groupId); } function preserveTabScrollPosition() { @@ -643,9 +667,8 @@ function handleTabGroupTransitionEnd(event: TransitionEvent) { refreshHorizontalTabOverflow(); } -function toggleTabGroup(tab: QueryTab) { +function toggleTabGroupById(groupId: string) { preserveTabScrollPosition(); - const groupId = tabGroupId(tab); if (collapsingTabGroups.value.has(groupId)) { cancelTabGroupCollapse(groupId); } @@ -663,6 +686,9 @@ function toggleTabGroup(tab: QueryTab) { function tabGroupIdsInPane() { if (settingsStore.editorSettings.tabGroupMode === "none") return []; + if (settingsStore.editorSettings.tabGroupMode === "sidebar") { + return [...new Set(stripSections.value.flatMap((section) => section.entries.filter((entry) => entry.kind === "header").map((entry) => entry.groupId)))]; + } return [...new Set(props.tabs.map((tab) => tabGroupId(tab)))]; } @@ -682,11 +708,12 @@ 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); - cancelTabGroupCollapse(groupId); - if (!collapsedTabGroups.value.has(groupId)) return; + const groupIds = settingsStore.editorSettings.tabGroupMode === "sidebar" ? stripSections.value.flatMap((section) => section.entries.filter((entry) => entry.kind === "tab" && entry.tab.id === tabId).flatMap((entry) => [entry.groupId, ...entry.ancestorIds])) : [tabGroupId(tab)]; + groupIds.forEach((groupId) => cancelTabGroupCollapse(groupId)); + const hidden = groupIds.filter((groupId) => collapsedTabGroups.value.has(groupId)); + if (!hidden.length) return; const next = new Set(collapsedTabGroups.value); - next.delete(groupId); + hidden.forEach((groupId) => next.delete(groupId)); collapsedTabGroups.value = next; } @@ -712,6 +739,56 @@ function tabGroupStyle(tab: QueryTab): CSSProperties { return groupColorStyle(resolvedTabGroupColor(tab)); } +function tabGroupStyleForEntry(entry: Extract): CSSProperties { + if (entry.level === "connection") return tabGroupStyle(entry.tab); + return tabGroupStyleForGroupId(entry.groupId); +} + +function tabGroupStyleForGroupId(groupId: string): CSSProperties { + let hash = 0; + for (const character of groupId) hash = (hash * 31 + character.codePointAt(0)!) | 0; + return groupColorStyle(tabGroupPalette[Math.abs(hash) % tabGroupPalette.length]!); +} + +function tabGroupStyleForTabEntry(entry: Extract): CSSProperties { + return settingsStore.editorSettings.tabGroupMode === "sidebar" ? tabGroupStyleForGroupId(entry.groupId) : tabGroupStyle(entry.tab); +} + +function tabGroupHeaderDepthStyle(entry: Extract): CSSProperties | undefined { + if (!isVerticalLayout.value || isTabBarCollapsed.value || settingsStore.editorSettings.tabGroupMode !== "sidebar") return undefined; + return { + "--tab-group-depth-offset": `${entry.depth * 0.75}rem`, + ...tabGroupTreeRowStyle(entry), + } as CSSProperties; +} + +function tabGroupTreeRowStyle(entry: StripEntry): CSSProperties | undefined { + if (!showTabTreeGuides.value) return undefined; + const parentColor = resolvedTabGroupColor(entry.tab); + const childColor = entry.kind === "header" && entry.level === "connection" ? parentColor : tabGroupPalette[Math.abs([...entry.groupId].reduce((hash, character) => (hash * 31 + character.codePointAt(0)!) | 0, 0)) % tabGroupPalette.length]!; + return { + "--tab-tree-guide-base-x": entry.kind === "header" ? "calc(var(--tab-group-root-rail-x) - var(--tab-group-header-inset))" : "var(--tab-group-root-rail-x)", + "--tab-tree-guide-child-x": entry.kind === "header" ? `calc(var(--tab-group-root-rail-x) - var(--tab-group-header-inset) + ${entry.depth * 0.75}rem)` : `calc(var(--tab-group-tab-inset) + ${entry.depth * 0.5}rem)`, + "--tab-tree-parent-color": parentColor, + "--tab-tree-child-color": childColor, + ...(entry.kind === "header" ? tabGroupStyleForEntry(entry) : tabGroupStyleForTabEntry(entry)), + } as CSSProperties; +} + +function tabGroupTabDepthStyle(entry: Extract): CSSProperties | undefined { + if (!isVerticalLayout.value || isTabBarCollapsed.value || settingsStore.editorSettings.tabGroupMode !== "sidebar") return undefined; + return { + "--tab-group-depth-offset": `${entry.depth * 0.75}rem`, + "--tab-group-vertical-depth-offset": `${entry.depth * 0.5}rem`, + } as CSSProperties; +} + +function isEntryHidden(entry: StripEntry) { + if (tabSearchQuery.value.trim() || settingsStore.editorSettings.tabGroupMode === "none") return false; + const ids = entry.kind === "header" ? entry.ancestorIds : [...entry.ancestorIds, entry.groupId]; + return ids.some((id) => collapsedTabGroups.value.has(id)); +} + const tabGroupEditorPreviewStyle = computed(() => groupColorStyle(editingTabGroupColor.value || editingTabGroupFallbackColor.value)); const editingTabGroupHasCustomization = computed(() => { const customization = settingsStore.editorSettings.tabGroupCustomizations[editingTabGroupKey.value]; @@ -781,9 +858,22 @@ function closeTabGroup(tab: QueryTab) { queryStore.closeTabsByIds(tabsToClose, finalActiveTabId); } +function closeTabsByIds(tabIds: string[]) { + const uniqueIds = [...new Set(tabIds)]; + if (!uniqueIds.length) return; + const finalActiveTabId = queryStore.activeTabId && !uniqueIds.includes(queryStore.activeTabId) ? queryStore.activeTabId : (queryStore.tabs.find((item) => !uniqueIds.includes(item.id))?.id ?? null); + queryStore.closeTabsByIds(uniqueIds, finalActiveTabId); +} + const tabOrganizationItems = computed(() => [ ...tabPlacementItems.value.map((item, index) => ({ ...item, value: `placement:${item.value}`, icon: { top: PanelTop, bottom: PanelBottom, left: PanelLeft, right: PanelRight }[item.value], groupLabel: index === 0 ? t("settings.tabPlacement") : undefined })), - ...tabGroupItems.value.map((item, index) => ({ ...item, value: `group:${item.value}`, icon: { none: Ungroup, "database-type": Database, database: Database, connection: Server }[item.value], separatorBefore: index === 0, groupLabel: index === 0 ? t("settings.tabGroup") : undefined })), + ...tabGroupItems.value.map((item, index) => ({ + ...item, + value: `group:${item.value}`, + icon: { none: Ungroup, "database-type": Database, database: Database, connection: Server, sidebar: Server }[item.value], + separatorBefore: index === 0, + groupLabel: index === 0 ? t("settings.tabGroup") : undefined, + })), ...tabSortItems.value.map((item, index) => ({ ...item, value: `sort:${item.value}`, icon: { manual: ListOrdered, "created-asc": Clock3, "title-asc": ArrowDownAZ }[item.value], separatorBefore: index === 0, groupLabel: index === 0 ? t("settings.tabSort") : undefined })), ]); const selectedTabOrganizationItems = computed(() => [`placement:${settingsStore.editorSettings.tabPlacement}`, `group:${settingsStore.editorSettings.tabGroupMode}`, `sort:${settingsStore.editorSettings.tabSortMode}`]); @@ -833,13 +923,59 @@ function getTabGroupMenuItems(tab: QueryTab): ContextMenuItem[] { ]; } +function getTabGroupHeaderMenuItems(entry: Extract): ContextMenuItem[] { + if (settingsStore.editorSettings.tabGroupMode !== "sidebar") return getTabGroupMenuItems(entry.tab); + const customization = tabGroupCustomization(entry.tab); + const customizationItems: ContextMenuItem[] = + entry.level === "connection" + ? [ + { label: t("contextMenu.editTabGroup"), action: () => openTabGroupEditor(entry.tab), icon: Pencil }, + { label: t("contextMenu.resetTabGroup"), action: () => resetTabGroupCustomization(entry.tab), icon: RotateCcw, visible: !!(customization?.name || customization?.color) }, + { label: "", separator: true }, + ] + : []; + return [ + ...customizationItems, + { label: t("contextMenu.collapseAll"), action: collapseAllTabGroups, icon: ChevronsDownUp }, + { label: t("contextMenu.expandAll"), action: () => expandAllTabGroups(), icon: ChevronsUpDown }, + { label: "", separator: true }, + { label: t("contextMenu.closeTabGroup"), action: () => closeTabsByIds(entry.tabIds), icon: X, variant: "destructive" }, + ]; +} + function openTabGroupContextMenu(event: MouseEvent, open: (event: MouseEvent) => void) { event.preventDefault(); document.getSelection()?.removeAllRanges(); open(event); } -type StripEntry = { kind: "header"; key: string; tab: QueryTab; pinned: boolean; count: number } | { kind: "tab"; key: string; tab: QueryTab; groupFirst: boolean; groupLast: boolean; grouping: boolean }; +type StripEntry = + | { + kind: "header"; + key: string; + tab: QueryTab; + groupId: string; + pinned: boolean; + count: number; + label: string; + level: "connection" | "database"; + depth: number; + tabIds: string[]; + ancestorIds: string[]; + treeGuides?: TabGroupTreeGuide[]; + } + | { + kind: "tab"; + key: string; + tab: QueryTab; + groupId: string; + depth: number; + ancestorIds: string[]; + groupFirst: boolean; + groupLast: boolean; + grouping: boolean; + treeGuides?: TabGroupTreeGuide[]; + }; /** * Flattens the strip's two sections (pinned, then regular) into render @@ -866,6 +1002,18 @@ const filteredRegularTabs = computed(() => { }); function buildStripEntries(section: QueryTab[], pinned: boolean): StripEntry[] { + if (settingsStore.editorSettings.tabGroupMode === "sidebar") { + const connectionlessEntries: StripEntry[] = section.filter((tab) => !tab.connectionId).map((tab) => ({ kind: "tab", key: tab.id, tab, groupId: "", depth: 0, ancestorIds: [], groupFirst: false, groupLast: false, grouping: false })); + const hierarchyEntries = buildConnectionDatabaseTabGroupStripEntries( + section.filter((tab) => tab.connectionId), + pinned, + ).map((entry): StripEntry => { + if (entry.kind === "tab") return entry; + const label = entry.level === "connection" ? tabGroupLabel(entry.tab) : hierarchyDatabaseLabel(entry.tab); + return { ...entry, label }; + }); + return [...connectionlessEntries, ...hierarchyEntries]; + } const entries: StripEntry[] = []; const grouping = settingsStore.editorSettings.tabGroupMode !== "none"; const groupKeys = grouping ? section.map(tabGroupKey) : []; @@ -878,9 +1026,10 @@ function buildStripEntries(section: QueryTab[], pinned: boolean): StripEntry[] { const first = grouping && (index === 0 || groupKeys[index - 1] !== groupKey); const last = grouping && (index === section.length - 1 || groupKeys[index + 1] !== groupKey); if (first) { - entries.push({ kind: "header", key: `header:${tab.id}`, tab, pinned, count: groupCounts.get(groupKey!) ?? 0 }); + const groupId = tabGroupId(tab); + entries.push({ kind: "header", key: `header:${groupId}`, tab, groupId, pinned, count: groupCounts.get(groupKey!) ?? 0, label: tabGroupLabel(tab), level: "connection", depth: 0, tabIds: section.filter((item) => tabGroupKey(item) === groupKey).map((item) => item.id), ancestorIds: [] }); } - entries.push({ kind: "tab", key: tab.id, tab, groupFirst: first, groupLast: last, grouping }); + entries.push({ kind: "tab", key: tab.id, tab, groupId: grouping ? tabGroupId(tab) : "", depth: 0, ancestorIds: grouping ? [tabGroupId(tab)] : [], groupFirst: first, groupLast: last, grouping }); }); return entries; } @@ -1490,6 +1639,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs? :show-chevron="false" :close-on-select="false" :match-trigger-width="false" + max-height="min(600px, calc(100vh - 16px))" align="end" @update:model-value="selectTabOrganizationItem" /> @@ -1516,6 +1666,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs? :show-chevron="false" :close-on-select="false" :match-trigger-width="false" + max-height="min(600px, calc(100vh - 16px))" @update:model-value="selectTabOrganizationItem" />
@@ -1559,24 +1710,28 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs? @wheel="hasHorizontalFixedRows && onHorizontalTabWheel($event)" >