feat(tabs): add connection database table hierarchy

This commit is contained in:
二丫讲梵
2026-10-03 23:37:47 +08:00
committed by GitHub
parent 233f7f7716
commit ff3c036f79
21 changed files with 637 additions and 39 deletions
@@ -7497,6 +7497,7 @@ onUnmounted(() => {
<SelectItem value="database-type">{{ t("settings.tabGroupDatabaseType") }}</SelectItem>
<SelectItem value="database">{{ t("settings.tabGroupDatabase") }}</SelectItem>
<SelectItem value="connection">{{ t("settings.tabGroupConnection") }}</SelectItem>
<SelectItem value="sidebar">{{ t("settings.tabGroupSidebar") }}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -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<string, Map<string, { connectionLabel: string; connectionTargetLabel: string }>>();
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<string>();
if (settingsStore.editorSettings.tabGroupMode !== "sidebar") return new Set([tabGroupId(activeTab)]);
const ids = new Set<string>();
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<StripEntry, { kind: "header" }>): 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<StripEntry, { kind: "tab" }>): CSSProperties {
return settingsStore.editorSettings.tabGroupMode === "sidebar" ? tabGroupStyleForGroupId(entry.groupId) : tabGroupStyle(entry.tab);
}
function tabGroupHeaderDepthStyle(entry: Extract<StripEntry, { kind: "header" }>): 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<StripEntry, { kind: "tab" }>): 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<StripEntry, { kind: "header" }>): 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"
/>
<div class="app-tab-strip relative h-full min-w-0 flex-1 overflow-hidden">
@@ -1559,24 +1710,28 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
@wheel="hasHorizontalFixedRows && onHorizontalTabWheel($event)"
>
<template v-for="entry in section.entries" :key="entry.key">
<CustomContextMenu v-if="entry.kind === 'header'" :items="() => getTabGroupMenuItems(entry.tab)" v-slot="{ onContextMenu }">
<CustomContextMenu v-if="entry.kind === 'header'" :items="() => getTabGroupHeaderMenuItems(entry)" v-slot="{ onContextMenu }">
<button
type="button"
class="tab-group-header"
:class="{ 'tab-group-header--collapsed': isTabGroupCollapsed(entry.tab), 'tab-group-header--active': isTabGroupActive(entry.tab) }"
:style="tabGroupStyle(entry.tab)"
:aria-expanded="!isTabGroupCollapsed(entry.tab)"
:title="tabGroupLabel(entry.tab)"
@click="toggleTabGroup(entry.tab)"
:class="{ 'tab-group-header--collapsed': isGroupCollapsed(entry.groupId), 'tab-group-header--active': isGroupActive(entry.groupId), 'tab-group-header--hidden': isEntryHidden(entry) }"
:style="[tabGroupStyleForEntry(entry), tabGroupHeaderDepthStyle(entry)]"
:data-tree-level="settingsStore.editorSettings.tabGroupMode === 'sidebar' ? entry.level : undefined"
:aria-expanded="!isGroupCollapsed(entry.groupId)"
:title="entry.label"
@click="toggleTabGroupById(entry.groupId)"
@contextmenu="openTabGroupContextMenu($event, onContextMenu)"
>
<TabGroupTreeGuides v-if="showTabTreeGuides" :guides="entry.treeGuides ?? []" :expanded="!isGroupCollapsed(entry.groupId) || !!tabSearchQuery.trim()" />
<span class="tab-group-header-content">
<span class="tab-group-marker" aria-hidden="true" />
<Pin v-if="entry.pinned" class="tab-group-pin" aria-hidden="true" />
<ChevronDown class="tab-group-chevron" :class="{ 'tab-group-chevron--collapsed': isTabGroupCollapsed(entry.tab) }" aria-hidden="true" />
<DatabaseIcon :db-type="tabDatabaseIconType(entry.tab)" class="tab-group-database-icon" aria-hidden="true" />
<span class="tab-group-label">{{ tabGroupLabel(entry.tab) }}</span>
<span v-if="isTabGroupCollapsed(entry.tab)" class="tab-group-count">{{ entry.count }}</span>
<ChevronDown class="tab-group-chevron" :class="{ 'tab-group-chevron--collapsed': isGroupCollapsed(entry.groupId) }" aria-hidden="true" />
<ConnectionIcon v-if="entry.level === 'connection' && settingsStore.editorSettings.tabGroupMode === 'sidebar'" :connection="connectionStore.getConfig(entry.tab.connectionId)" class="tab-group-database-icon tab-group-connection-icon" aria-hidden="true" />
<DatabaseIcon v-else-if="entry.level === 'connection'" :db-type="tabDatabaseIconType(entry.tab)" class="tab-group-database-icon tab-group-connection-icon" aria-hidden="true" />
<Database v-else class="tab-group-database-icon tab-group-library-icon text-yellow-500" aria-hidden="true" />
<span class="tab-group-label">{{ entry.label }}</span>
<span v-if="isGroupCollapsed(entry.groupId)" class="tab-group-count">{{ entry.count }}</span>
</span>
</button>
</CustomContextMenu>
@@ -1586,16 +1741,18 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
'tab-group-entry',
isClassicLayout && !isVerticalLayout ? 'h-full' : '',
{
'tab-group-entry--collapsing': entry.grouping && !tabSearchQuery.trim() && collapsingTabGroups.has(tabGroupId(entry.tab)),
'tab-group-entry--collapsed': entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab),
'tab-group-entry--collapsing': entry.grouping && !tabSearchQuery.trim() && collapsingTabGroups.has(entry.groupId),
'tab-group-entry--collapsed': entry.grouping && !tabSearchQuery.trim() && isEntryHidden(entry),
},
]"
:data-tab-group-id="entry.grouping ? tabGroupId(entry.tab) : undefined"
:aria-hidden="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
:inert="entry.grouping && !tabSearchQuery.trim() && isTabGroupCollapsed(entry.tab)"
:data-tab-group-id="entry.grouping ? entry.groupId : undefined"
:style="tabGroupTreeRowStyle(entry)"
:aria-hidden="entry.grouping && !tabSearchQuery.trim() && isEntryHidden(entry)"
:inert="entry.grouping && !tabSearchQuery.trim() && isEntryHidden(entry)"
@contextmenu="onContextMenu"
@transitionend.self="handleTabGroupTransitionEnd"
>
<TabGroupTreeGuides v-if="showTabTreeGuides" :guides="entry.treeGuides ?? []" />
<Tooltip :open="openTabTooltipId === entry.tab.id" @update:open="updateTabTooltipOpen(entry.tab.id, $event)">
<TooltipTrigger as-child>
<div
@@ -1610,7 +1767,7 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
'tab-group-tab--last': entry.grouping && entry.groupLast,
},
]"
:style="[tabColorStyle(entry.tab), entry.grouping ? tabGroupStyle(entry.tab) : undefined, tabDropStyle(entry.tab)]"
:style="[tabColorStyle(entry.tab), entry.grouping ? tabGroupStyleForTabEntry(entry) : undefined, tabDropStyle(entry.tab), entry.grouping ? tabGroupTabDepthStyle(entry) : undefined]"
:data-active-tab="isTabActive(entry.tab)"
:data-tab-id="entry.tab.id"
@pointerdown="handleTabPointerDown($event, entry.tab)"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { TabGroupTreeGuide } from "@/lib/tabs/tabGroupTree";
withDefaults(
defineProps<{
guides: TabGroupTreeGuide[];
expanded?: boolean;
}>(),
{ expanded: true },
);
</script>
<template>
<span class="tab-tree-guides" aria-hidden="true">
<template v-for="guide in guides" :key="guide.depth">
<span v-if="guide.kind !== 'start' || expanded" class="tab-tree-guide" :class="`tab-tree-guide--${guide.kind}`" :data-depth="guide.depth" :style="{ '--tab-tree-guide-depth': guide.depth }" />
</template>
</span>
</template>
@@ -75,6 +75,8 @@ function mountBar(groupId: string, tabs: ReturnType<ReturnType<typeof useQuerySt
closeTabGroup: "Close group",
editTabGroup: "Edit group",
resetTabGroup: "Reset group",
collapseAll: "Collapse all",
expandAll: "Expand all",
pinTab: "Pin",
unpinTab: "Unpin",
fullTabTitle: "Full title",
@@ -338,6 +340,138 @@ describe("EditorGroupTabBar group behavior", () => {
host.remove();
});
it("renders connection-to-database-to-tab guide segments for multiple databases", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "sidebar";
settings.editorSettings.tabPlacement = "left";
store.createTab("mysql-1", "app", "App 1", "query");
store.createTab("mysql-1", "app", "App 2", "query");
store.createTab("mysql-1", "audit", "Audit", "query");
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), store.activeTabId, pinia);
await settle();
expect(host.querySelectorAll(".tab-group-header")).toHaveLength(3);
expect(host.querySelectorAll("[data-tree-level='connection']")).toHaveLength(1);
expect(host.querySelectorAll("[data-tree-level='database']")).toHaveLength(2);
expect(host.querySelector("[data-tree-level='connection'] .tab-group-connection-icon")).not.toBeNull();
expect(host.querySelector("[data-tree-level='database'] .tab-group-library-icon")).not.toBeNull();
expect(host.querySelectorAll(".tab-tree-guide--through")).toHaveLength(2);
expect(host.querySelectorAll(".tab-tree-guide--branch")).toHaveLength(2);
expect(host.querySelectorAll(".tab-tree-guide--last-branch")).toHaveLength(3);
const databaseHeader = host.querySelector<HTMLElement>("[data-tree-level='database']")!;
databaseHeader.click();
await settle();
expect(databaseHeader.querySelector(".tab-tree-guide--start")).toBeNull();
app.unmount();
host.remove();
});
it("renders standalone plugin workbenches outside the connection hierarchy", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "sidebar";
settings.editorSettings.tabPlacement = "left";
const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server" });
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), pluginTabId, pinia);
await settle();
expect(host.querySelectorAll(".tab-group-header")).toHaveLength(0);
expect(host.querySelector(`[data-tab-id="${pluginTabId}"]`)?.textContent).toContain("SSH server");
app.unmount();
host.remove();
});
it("uses plugin-aware icons for sidebar connection headers", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [
{
id: "ssh-1",
name: "SSH",
db_type: "plugin",
driver_profile: "plugin",
host: "127.0.0.1",
port: 22,
color: "",
plugin_id: "io.dbx.ssh",
plugin_connection_provider: "io.dbx.ssh.connection",
} as ConnectionConfig,
];
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "sidebar";
settings.editorSettings.tabPlacement = "left";
const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1" });
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), pluginTabId, pinia);
for (let index = 0; index < 20 && !host.querySelector("[data-tree-level='connection'] img"); index += 1) {
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5));
}
expect(host.querySelector("[data-tree-level='connection'] img")).not.toBeNull();
app.unmount();
host.remove();
});
it("labels Redis database branches with their indexes and aliases", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [
{
id: "redis-1",
name: "Redis Cache",
db_type: "redis",
driver_profile: "redis",
host: "127.0.0.1",
port: 6379,
color: "",
redis_database_aliases: { "1": "Sessions" },
} as ConnectionConfig,
];
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "sidebar";
settings.editorSettings.tabPlacement = "left";
const db0 = store.createTab("redis-1", "0", "Redis 0", "redis");
store.createTab("redis-1", "1", "Redis 1", "redis");
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), db0, pinia);
await settle();
const headers = Array.from(host.querySelectorAll<HTMLButtonElement>(".tab-group-header"));
expect(headers.map((header) => header.title)).toEqual(["Redis Cache", "db0", "db1 · Sessions"]);
app.unmount();
host.remove();
});
it("collapses every sidebar connection and database branch from a header menu", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "sidebar";
settings.editorSettings.tabPlacement = "left";
store.createTab("mysql-1", "app", "App", "query");
store.createTab("mysql-1", "audit", "Audit", "query");
store.createTab("pg-1", "app", "Postgres", "query");
const { app, host } = mountBar(store.groups[0]!.id, store.tabs.slice(), store.activeTabId, pinia);
await settle();
host.querySelector<HTMLElement>(".tab-group-header")!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
await settle();
const menu = document.body.querySelector<HTMLElement>("[data-dbx-context-menu]")!;
const collapseAll = Array.from(menu.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.includes("Collapse all"));
expect(collapseAll).toBeDefined();
collapseAll!.click();
await settle();
expect(Array.from(host.querySelectorAll(".tab-group-header"), (header) => header.getAttribute("aria-expanded"))).toEqual(["false", "false", "false", "false", "false"]);
expect(host.querySelectorAll(".tab-group-entry--collapsed")).toHaveLength(3);
app.unmount();
host.remove();
});
it("sizes the header chevron and rotates it only while the cluster is collapsed", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
@@ -460,6 +460,10 @@
box-shadow 150ms ease;
}
.tab-group-header--hidden {
display: none;
}
.tab-group-header:focus-visible {
box-shadow: 0 0 0 2px var(--ring);
}
@@ -477,6 +481,10 @@
transform 120ms ease;
}
.tab-group-entry .tab-group-tab {
margin-inline-start: var(--tab-group-depth-offset, 0rem);
}
.tab-group-marker {
width: 0.22rem;
height: 1rem;
@@ -1177,6 +1185,10 @@ html.dbx-legacy-webview .vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[d
padding-inline: 0.55rem;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header-content {
padding-inline-start: calc(0.55rem + var(--tab-group-depth-offset, 0rem));
}
.vertical-tab-layout .tab-group-header:hover .tab-group-header-content,
.vertical-tab-layout .tab-group-header--active .tab-group-header-content {
background: var(--tab-group-soft);
@@ -1200,6 +1212,83 @@ html.dbx-legacy-webview .vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[d
pointer-events: none;
}
/* Sidebar mode uses row-local guide segments. Keeping the guides on each
* rendered row makes sibling databases connect correctly and naturally
* shortens the rail when a group is collapsed. */
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header::after,
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-tab::before,
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-tab::after {
display: none;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header,
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-entry {
position: relative;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-header {
--tab-tree-guide-before: -0.35rem;
--tab-tree-guide-after: calc(0rem - var(--tab-group-header-gap));
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guides {
position: absolute;
z-index: 1;
inset: 0;
pointer-events: none;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide {
position: absolute;
top: 0;
bottom: 0;
/* Keep the guide's center aligned with the marker and its branches. */
left: calc(var(--tab-tree-guide-base-x) + var(--tab-tree-guide-depth) * 0.75rem - 0.5px);
width: 1px;
color: var(--tab-tree-parent-color);
opacity: 0.35;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide[data-depth="1"] {
color: var(--tab-tree-child-color);
}
/* Every non-final sibling owns a full-height rail and a centered branch.
* Only the final sibling ends the rail at its center. Header rails also own
* their margins, so collapsed subtrees leave no gaps between database rows. */
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide::before {
content: "";
position: absolute;
top: var(--tab-tree-guide-before, 0);
bottom: var(--tab-tree-guide-after, 0);
left: 0;
width: 1px;
background: currentColor;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--start::before {
top: 50%;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--last-branch::before {
bottom: 50%;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--branch::after,
.vertical-tab-layout[data-group-mode="sidebar"] .tab-tree-guide--last-branch::after {
content: "";
position: absolute;
top: calc(50% - 0.5px);
left: 0.5px;
width: calc(var(--tab-tree-guide-child-x) - (var(--tab-tree-guide-base-x) + var(--tab-tree-guide-depth) * 0.75rem));
height: 1px;
background: currentColor;
}
.vertical-tab-layout[data-group-mode="sidebar"] .tab-group-entry--collapsed .tab-tree-guides {
display: none;
}
.vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed) .tab-group-marker {
position: relative;
z-index: 2;
@@ -1207,8 +1296,8 @@ html.dbx-legacy-webview .vertical-tab-layout .app-tab-pill:not(.tab-group-tab)[d
.vertical-tab-layout .tab-group-tab {
position: relative;
width: calc(100% - var(--tab-group-tab-inset) - 0.25rem);
margin-inline: var(--tab-group-tab-inset) 0.25rem;
width: calc(100% - var(--tab-group-tab-inset) - var(--tab-group-vertical-depth-offset, 0rem) - 0.25rem);
margin-inline: calc(var(--tab-group-tab-inset) + var(--tab-group-vertical-depth-offset, 0rem)) 0.25rem;
padding-inline: 0.2rem 0.25rem !important;
border-color: transparent;
}
@@ -41,6 +41,7 @@ const props = withDefaults(
closeOnSelect?: boolean;
label?: string;
matchTriggerWidth?: boolean;
maxHeight?: string;
}>(),
{
ariaLabel: undefined,
@@ -65,6 +66,7 @@ const props = withDefaults(
closeOnSelect: true,
label: undefined,
matchTriggerWidth: true,
maxHeight: "min(420px, calc(100vh - 16px))",
},
);
@@ -85,7 +87,7 @@ const menuStyle = computed(() => ({
left: `${x.value}px`,
top: `${y.value}px`,
minWidth: props.matchTriggerWidth ? `${minWidth.value}px` : undefined,
maxHeight: "min(420px, calc(100vh - 16px))",
maxHeight: props.maxHeight,
}));
function onScroll(event: Event) {
+1
View File
@@ -7081,6 +7081,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Verilənlər bazasının növünə görə qruplaşdır",
tabGroupDatabase: "Verilənlər bazasına görə qruplaşdır",
tabGroupConnection: "Əlaqəyə görə qruplaşdır",
tabGroupSidebar: "Əlaqə, verilənlər bazası və cədvələ görə qruplaşdır",
tabSort: "Sıralama meyarı",
tabSortManual: "Əl ilə sıralama",
tabSortCreated: "Yaradılma vaxtı",
+1
View File
@@ -7934,6 +7934,7 @@ export default {
tabGroupDatabaseType: "Group by database type",
tabGroupDatabase: "Group by database",
tabGroupConnection: "Group by connection",
tabGroupSidebar: "Group by connection, database and table",
tabSort: "Sort by",
tabSortManual: "Manual order",
tabSortCreated: "Creation time",
+1
View File
@@ -7271,6 +7271,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Agrupar por tipo de base de datos",
tabGroupDatabase: "Agrupar por base de datos",
tabGroupConnection: "Agrupar por conexión",
tabGroupSidebar: "Agrupar por conexión, base de datos y tabla",
tabSort: "Ordenar por",
tabSortManual: "Orden manual",
tabSortCreated: "Hora de creación",
+1
View File
@@ -7745,6 +7745,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Kelompokkan berdasarkan jenis database",
tabGroupDatabase: "Kelompokkan berdasarkan database",
tabGroupConnection: "Kelompokkan berdasarkan koneksi",
tabGroupSidebar: "Kelompokkan berdasarkan koneksi, database, dan tabel",
tabSort: "Urutkan berdasarkan",
tabSortManual: "Urutan manual",
tabSortCreated: "Waktu pembuatan",
+1
View File
@@ -7049,6 +7049,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Raggruppa per tipo di database",
tabGroupDatabase: "Raggruppa per database",
tabGroupConnection: "Raggruppa per connessione",
tabGroupSidebar: "Raggruppa per connessione, database e tabella",
tabSort: "Ordina per",
tabSortManual: "Ordine manuale",
tabSortCreated: "Data di creazione",
+1
View File
@@ -7080,6 +7080,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "データベース種別でグループ化",
tabGroupDatabase: "データベースごとにグループ化",
tabGroupConnection: "接続ごとにグループ化",
tabGroupSidebar: "接続、データベース、テーブルでグループ化",
tabSort: "並べ替え",
tabSortManual: "手動順",
tabSortCreated: "作成日時",
+1
View File
@@ -6855,6 +6855,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "데이터베이스 유형별 그룹화",
tabGroupDatabase: "데이터베이스별 그룹화",
tabGroupConnection: "연결별 그룹화",
tabGroupSidebar: "연결, 데이터베이스 및 테이블별 그룹화",
tabSort: "정렬 기준",
tabSortManual: "수동 순서",
tabSortCreated: "생성 시간",
+1
View File
@@ -7051,6 +7051,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Agrupar por tipo de banco de dados",
tabGroupDatabase: "Agrupar por banco de dados",
tabGroupConnection: "Agrupar por conexão",
tabGroupSidebar: "Agrupar por conexão, banco de dados e tabela",
tabSort: "Ordenar por",
tabSortManual: "Ordem manual",
tabSortCreated: "Hora de criação",
+1
View File
@@ -8264,6 +8264,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Группировать по типу базы данных",
tabGroupDatabase: "Группировать по базе данных",
tabGroupConnection: "Группировать по подключению",
tabGroupSidebar: "Группировать по подключению, базе данных и таблице",
tabSort: "Сортировать по",
tabSortManual: "Ручной порядок",
tabSortCreated: "Время создания",
+1
View File
@@ -6963,6 +6963,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "Veritabanı türüne göre grupla",
tabGroupDatabase: "Veritabanına göre grupla",
tabGroupConnection: "Bağlantıya göre grupla",
tabGroupSidebar: "Bağlantı, veritabanı ve tabloya göre grupla",
tabSort: "Sıralama ölçütü",
tabSortManual: "Elle sıralama",
tabSortCreated: "Oluşturulma zamanı",
+1
View File
@@ -7920,6 +7920,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "按数据库类型分组",
tabGroupDatabase: "按数据库分组",
tabGroupConnection: "按连接分组",
tabGroupSidebar: "按连接、库和表分组",
tabSort: "排序方式",
tabSortManual: "手动排序",
tabSortCreated: "按创建时间",
+1
View File
@@ -6403,6 +6403,7 @@ export default withEnglishFallback({
tabGroupDatabaseType: "依資料庫類型分組",
tabGroupDatabase: "依資料庫分組",
tabGroupConnection: "依連線分組",
tabGroupSidebar: "依連線、資料庫與資料表分組",
tabSort: "排序方式",
tabSortManual: "手動排序",
tabSortCreated: "依建立時間",
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { buildConnectionDatabaseTabGroupStripEntries } from "@/lib/tabs/tabGroupTree";
import type { QueryTab } from "@/types/database";
function tab(id: string, connectionId: string, database: string, catalog?: string): QueryTab {
return { id, connectionId, database, catalog, title: id } as QueryTab;
}
describe("buildConnectionDatabaseTabGroupStripEntries", () => {
it("builds connection, database and tab levels without sidebar folders", () => {
const entries = buildConnectionDatabaseTabGroupStripEntries([tab("users", "mysql", "app"), tab("orders", "mysql", "app"), tab("audit", "mysql", "logs"), tab("users", "pg", "app")], false);
expect(entries.map((entry) => (entry.kind === "header" ? `${entry.level}:${entry.label}` : `tab:${entry.tab.id}`))).toEqual(["connection:mysql", "database:app", "tab:users", "tab:orders", "database:logs", "tab:audit", "connection:pg", "database:app", "tab:users"]);
expect(entries.filter((entry) => entry.kind === "header").every((entry) => entry.level === "connection" || entry.level === "database")).toBe(true);
const mysqlConnection = entries[0];
expect(mysqlConnection?.kind === "header" && mysqlConnection.treeGuides).toEqual([{ depth: 0, kind: "start" }]);
const firstDatabase = entries[1];
expect(firstDatabase?.kind === "header" && firstDatabase.treeGuides).toEqual([
{ depth: 0, kind: "branch" },
{ depth: 1, kind: "start" },
]);
const firstDatabaseLastTab = entries[3];
expect(firstDatabaseLastTab?.kind === "tab" && firstDatabaseLastTab.treeGuides).toEqual([
{ depth: 0, kind: "through" },
{ depth: 1, kind: "last-branch" },
]);
const postgresConnection = entries[6];
expect(postgresConnection?.kind === "header" && postgresConnection.treeGuides).toEqual([{ depth: 0, kind: "start" }]);
});
it("keeps pinned and regular hierarchy IDs separate", () => {
const regular = buildConnectionDatabaseTabGroupStripEntries([tab("users", "mysql", "app")], false);
const pinned = buildConnectionDatabaseTabGroupStripEntries([tab("users", "mysql", "app")], true);
expect(regular[0]?.groupId).not.toBe(pinned[0]?.groupId);
expect(regular[1]?.groupId).not.toBe(pinned[1]?.groupId);
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { QueryTab } from "@/types/database";
export type TabHierarchyLevel = "connection" | "database";
export interface TabGroupTreeGuide {
depth: number;
kind: "start" | "through" | "branch" | "last-branch";
}
export interface ConnectionDatabaseTabGroupHeaderEntry {
kind: "header";
key: string;
groupId: string;
label: string;
level: TabHierarchyLevel;
depth: number;
count: number;
pinned: boolean;
tab: QueryTab;
tabIds: string[];
ancestorIds: string[];
treeGuides: TabGroupTreeGuide[];
}
export interface ConnectionDatabaseTabGroupTabEntry {
kind: "tab";
key: string;
groupId: string;
tab: QueryTab;
depth: number;
groupFirst: boolean;
groupLast: boolean;
grouping: true;
ancestorIds: string[];
treeGuides: TabGroupTreeGuide[];
}
export type ConnectionDatabaseTabGroupStripEntry = ConnectionDatabaseTabGroupHeaderEntry | ConnectionDatabaseTabGroupTabEntry;
const CONNECTION_GROUP_ID_PREFIX = "sidebar-connection:";
const DATABASE_GROUP_ID_PREFIX = "sidebar-database:";
function sectionPrefix(pinned: boolean): string {
return pinned ? "fixed:" : "regular:";
}
function connectionGroupId(connectionId: string, pinned: boolean): string {
return `${sectionPrefix(pinned)}${CONNECTION_GROUP_ID_PREFIX}${connectionId}`;
}
function databaseGroupKey(tab: QueryTab): string {
return JSON.stringify([tab.connectionId, tab.catalog || "", tab.database || ""]);
}
function databaseGroupId(tab: QueryTab, pinned: boolean): string {
return `${sectionPrefix(pinned)}${DATABASE_GROUP_ID_PREFIX}${databaseGroupKey(tab)}`;
}
/**
* Projects the current pane into a stable three-level hierarchy:
* connection -> database -> table/query tab.
*
* The sidebar's user-created folders are deliberately ignored. Tabs without
* a database still receive a database bucket so every tab follows the same
* shape and remains reachable through the connection branch.
*/
export function buildConnectionDatabaseTabGroupStripEntries(tabs: QueryTab[], pinned: boolean): ConnectionDatabaseTabGroupStripEntry[] {
if (tabs.length === 0) return [];
const tabsByConnection = new Map<string, QueryTab[]>();
for (const tab of tabs) {
const bucket = tabsByConnection.get(tab.connectionId) ?? [];
bucket.push(tab);
tabsByConnection.set(tab.connectionId, bucket);
}
const entries: ConnectionDatabaseTabGroupStripEntry[] = [];
for (const [connectionId, connectionTabs] of tabsByConnection) {
const connectionIdValue = connectionGroupId(connectionId, pinned);
const databaseBuckets = new Map<string, QueryTab[]>();
for (const tab of connectionTabs) {
const key = databaseGroupKey(tab);
const bucket = databaseBuckets.get(key) ?? [];
bucket.push(tab);
databaseBuckets.set(key, bucket);
}
const databaseEntries = [...databaseBuckets.values()];
entries.push({
kind: "header",
key: `header:${connectionIdValue}`,
groupId: connectionIdValue,
label: connectionId,
level: "connection",
depth: 0,
count: connectionTabs.length,
pinned,
tab: connectionTabs[0]!,
tabIds: connectionTabs.map((tab) => tab.id),
ancestorIds: [],
treeGuides: [{ depth: 0, kind: "start" }],
});
databaseEntries.forEach((databaseTabs, databaseIndex) => {
const lastDatabase = databaseIndex === databaseEntries.length - 1;
const representativeTab = databaseTabs[0]!;
const databaseId = databaseGroupId(representativeTab, pinned);
entries.push({
kind: "header",
key: `header:${databaseId}`,
groupId: databaseId,
label: representativeTab.database || representativeTab.catalog || connectionId,
level: "database",
depth: 1,
count: databaseTabs.length,
pinned,
tab: representativeTab,
tabIds: databaseTabs.map((tab) => tab.id),
ancestorIds: [connectionIdValue],
treeGuides: [
{ depth: 0, kind: lastDatabase ? "last-branch" : "branch" },
{ depth: 1, kind: "start" },
],
});
databaseTabs.forEach((tab, index) => {
entries.push({
kind: "tab",
key: tab.id,
groupId: databaseId,
tab,
depth: 2,
groupFirst: index === 0,
groupLast: index === databaseTabs.length - 1,
grouping: true,
ancestorIds: [connectionIdValue, databaseId],
// Continue the connection rail across this database's tabs only
// when another database follows. Every table owns a centered branch
// to its own database rail; only the final table ends that rail.
treeGuides: [...(lastDatabase ? [] : [{ depth: 0, kind: "through" as const }]), { depth: 1, kind: index === databaseTabs.length - 1 ? "last-branch" : "branch" }],
});
});
});
}
return entries;
}
+1 -1
View File
@@ -706,7 +706,7 @@ const TAB_LAYOUT_MODES = ["scroll", "wrap"] as const;
export type TabLayoutMode = (typeof TAB_LAYOUT_MODES)[number];
const TAB_PLACEMENTS = ["top", "bottom", "left", "right"] as const;
export type TabPlacement = (typeof TAB_PLACEMENTS)[number];
const TAB_GROUP_MODES = ["none", "database-type", "database", "connection"] as const;
const TAB_GROUP_MODES = ["none", "database-type", "database", "connection", "sidebar"] as const;
export type TabGroupMode = (typeof TAB_GROUP_MODES)[number];
export interface TabGroupCustomization {
name?: string;