mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
feat(tabs): add connection database table hierarchy
This commit is contained in:
@@ -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>
|
||||
+134
@@ -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) {
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7080,6 +7080,7 @@ export default withEnglishFallback({
|
||||
tabGroupDatabaseType: "データベース種別でグループ化",
|
||||
tabGroupDatabase: "データベースごとにグループ化",
|
||||
tabGroupConnection: "接続ごとにグループ化",
|
||||
tabGroupSidebar: "接続、データベース、テーブルでグループ化",
|
||||
tabSort: "並べ替え",
|
||||
tabSortManual: "手動順",
|
||||
tabSortCreated: "作成日時",
|
||||
|
||||
@@ -6855,6 +6855,7 @@ export default withEnglishFallback({
|
||||
tabGroupDatabaseType: "데이터베이스 유형별 그룹화",
|
||||
tabGroupDatabase: "데이터베이스별 그룹화",
|
||||
tabGroupConnection: "연결별 그룹화",
|
||||
tabGroupSidebar: "연결, 데이터베이스 및 테이블별 그룹화",
|
||||
tabSort: "정렬 기준",
|
||||
tabSortManual: "수동 순서",
|
||||
tabSortCreated: "생성 시간",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8264,6 +8264,7 @@ export default withEnglishFallback({
|
||||
tabGroupDatabaseType: "Группировать по типу базы данных",
|
||||
tabGroupDatabase: "Группировать по базе данных",
|
||||
tabGroupConnection: "Группировать по подключению",
|
||||
tabGroupSidebar: "Группировать по подключению, базе данных и таблице",
|
||||
tabSort: "Сортировать по",
|
||||
tabSortManual: "Ручной порядок",
|
||||
tabSortCreated: "Время создания",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -7920,6 +7920,7 @@ export default withEnglishFallback({
|
||||
tabGroupDatabaseType: "按数据库类型分组",
|
||||
tabGroupDatabase: "按数据库分组",
|
||||
tabGroupConnection: "按连接分组",
|
||||
tabGroupSidebar: "按连接、库和表分组",
|
||||
tabSort: "排序方式",
|
||||
tabSortManual: "手动排序",
|
||||
tabSortCreated: "按创建时间",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user