feat(tab): 支持标签显示直属分组名称 (#860)

## 背景

Issue #853 希望在“设置 → 工作区 → Tab 标签”中增加连接分组名称展示能力,便于在多个服务器分组之间快速识别当前标签来源。

## 变更点

- 在 Tab 标签显示元素中增加“分组”选项,双行布局启用后默认放在第二行
- 根据连接 ID 解析连接直接所属的分组名称,并在分组重命名、移动连接或删除分组后即时刷新
- 嵌套分组场景仅展示连接的直属分组名称,不继承、不拼接父级分组路径
- 未分组连接的分组值保持为空,不生成多余分隔符
- 为长分组名称增加收缩与省略显示,并补充六种语言文案
- 增加分组展示、未分组回退及嵌套直属分组边界测试

## 影响范围

- 仅影响工作区 Tab 标签的可选显示内容
- “分组”默认不启用,不改变现有用户的 Tab 布局和显示结果
- 不涉及后端接口、连接配置、数据库结构或数据迁移

## 验证

- `npm run test -- src/utils/tabDisplay.test.ts`:27/27 通过
- `npx tsc --noEmit --pretty false`:通过
- `npx vite build`:通过
- 六套 `shared/i18n/*.json`:解析通过
- `git diff --check origin/dev...HEAD`:通过

Closes #853
This commit is contained in:
Syngnat
2026-08-06 20:49:11 +08:00
committed by GitHub
10 changed files with 144 additions and 14 deletions

View File

@@ -12,6 +12,8 @@ import type { ExternalSQLDirectory, SavedConnection, SavedQuery, TabData } from
import { t } from '../i18n';
import {
buildTabDisplayModel,
buildConnectionGroupNameIndex,
getConnectionGroupName,
resolveConnectionHostSummary,
type TabDisplayPart,
type TabDisplayModel,
@@ -733,6 +735,7 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
const tabs = useWorkbenchTabs();
const detachedWorkbenchWindows = useStore(state => state.detachedWorkbenchWindows);
const connections = useStore(state => state.connections);
const connectionTags = useStore(state => state.connectionTags);
const savedQueries = useStore(state => state.savedQueries);
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
const recentConnectionTargets = useStore(state => state.recentConnectionTargets);
@@ -759,6 +762,10 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
() => tabs.filter((tab) => !detachedTabIdSet.has(tab.id)),
[detachedTabIdSet, tabs],
);
const connectionGroupNameById = useMemo(
() => buildConnectionGroupNameIndex(connectionTags),
[connectionTags],
);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const tabWorkbenchRef = useRef<HTMLDivElement>(null);
const [v2TabWidth, setV2TabWidth] = useState(V2_WORKBENCH_TAB_MAX_WIDTH);
@@ -1052,7 +1059,7 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
const tab = dockedTabs.find((item) => item.id === sourceId);
const connection = connections.find((conn) => conn.id === tab?.connectionId);
const displayModel = tab
? buildTabDisplayModel(tab, connection, appearance.tabDisplay, t)
? buildTabDisplayModel(tab, connection, appearance.tabDisplay, t, getConnectionGroupName(connectionGroupNameById, tab.connectionId))
: null;
const title = displayModel?.fullTitle || tab?.title || t('tab_manager.detached.title_fallback');
const pointerEvent = event.activatorEvent as PointerEvent | MouseEvent | undefined;
@@ -1242,10 +1249,10 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
const hasDoubleLineTabLabel = useMemo(() => (
dockedTabs.some((tab) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t, getConnectionGroupName(connectionGroupNameById, tab.connectionId));
return displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
})
), [appearance.tabDisplay, connections, dockedTabs]);
), [appearance.tabDisplay, connections, connectionGroupNameById, dockedTabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
<DefaultTabBar {...tabBarProps}>
@@ -1255,7 +1262,7 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
const items = useMemo(() => dockedTabs.map((tab, index) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t, getConnectionGroupName(connectionGroupNameById, tab.connectionId));
const environment = connection
? resolveConnectionEnvironmentPresentation(connection, t)
: undefined;
@@ -1339,7 +1346,7 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
closable: !isV2Ui,
children: <WorkbenchTabContent tab={tab} isActive={tabIsActive} />,
};
}), [dockedTabs, dockedActiveTabId, tabs, connections, appearance.tabDisplay, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, detachTabToWindow, isV2Ui, languagePreference]);
}), [dockedTabs, dockedActiveTabId, tabs, connections, connectionGroupNameById, appearance.tabDisplay, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, detachTabToWindow, isV2Ui, languagePreference]);
const queryCapableConnections = useMemo(
() => connections.filter((connection) => getDataSourceCapabilities(connection.config).supportsQueryEditor),

View File

@@ -4,6 +4,7 @@ import { t as catalogTranslate } from '../i18n/catalog';
import type { SavedConnection, TabData } from '../types';
import {
applyTabDisplaySettingsPatch,
buildConnectionGroupNameIndex,
buildTabDisplayModel,
buildTabDisplayTitle,
DEFAULT_TAB_DISPLAY_SETTINGS,
@@ -481,7 +482,78 @@ describe('tabDisplay', () => {
layout: 'double',
primaryElements: ['object', 'kind'],
secondaryElements: ['host'],
})).toEqual(['object', 'kind', 'host', 'connection', 'database', 'schema']);
})).toEqual(['object', 'kind', 'host', 'connection', 'database', 'schema', 'group']);
});
it('renders the connection group name when the group element is enabled', () => {
const connection: SavedConnection = {
id: 'pg-1',
name: 'Postgres PROD',
config: {
type: 'postgres',
host: '10.0.0.9',
port: 5432,
user: 'postgres',
database: 'analytics',
},
};
const tableTab: TabData = {
id: 'pg-1-analytics-table-reporting.events',
title: 'reporting.events',
type: 'table',
connectionId: 'pg-1',
dbName: 'analytics',
tableName: 'reporting.events',
};
const grouped = buildTabDisplayModel(tableTab, connection, {
layout: 'double',
primaryElements: ['object'],
secondaryElements: ['group', 'connection'],
}, undefined, '核心交易库');
expect(grouped.secondaryText).toBe('核心交易库·[PROD]');
expect(grouped.fullTitle).toBe('events · 核心交易库·[PROD]');
});
it('omits the group element when the connection has no group name', () => {
const tableTab: TabData = {
id: 'pg-1-analytics-table-events',
title: 'events',
type: 'table',
connectionId: 'pg-1',
dbName: 'analytics',
tableName: 'events',
};
const ungrouped = buildTabDisplayModel(tableTab, undefined, {
layout: 'double',
primaryElements: ['object'],
secondaryElements: ['group', 'kind'],
}, undefined, '');
expect(ungrouped.secondaryText).toBe('TABLE');
expect(ungrouped.fullTitle).toBe('events · TABLE');
});
it('resolves a connection group name from the first matching tag', () => {
const index = buildConnectionGroupNameIndex([
{ id: 'tag-a', name: '交易库', connectionIds: ['conn-1', 'conn-2'] },
{ id: 'tag-b', name: '日志库', connectionIds: ['conn-3'] },
]);
expect(index.get('conn-1')).toBe('交易库');
expect(index.get('conn-3')).toBe('日志库');
expect(index.get('conn-unknown')).toBeUndefined();
});
it('resolves only the direct group name for a nested connection', () => {
const index = buildConnectionGroupNameIndex([
{ id: 'tag-root', name: '生产环境', connectionIds: [] },
{ id: 'tag-child', name: '核心交易库', parentTagId: 'tag-root', connectionIds: ['conn-1'] },
]);
expect(index.get('conn-1')).toBe('核心交易库');
});
it('keeps separate single-line and double-line settings when switching layouts', () => {

View File

@@ -1,9 +1,9 @@
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
import type { ConnectionConfig, ConnectionTag, SavedConnection, TabData } from '../types';
import { t as catalogTranslate } from '../i18n/catalog';
import type { I18nParams } from '../i18n/types';
import { resolveLocalizedUntitledQueryTitle } from './queryTabTitle';
export const TAB_DISPLAY_ELEMENT_KEYS = ['object', 'kind', 'connection', 'database', 'schema', 'host'] as const;
export const TAB_DISPLAY_ELEMENT_KEYS = ['object', 'kind', 'connection', 'database', 'schema', 'host', 'group'] as const;
export type TabDisplayElementKey = typeof TAB_DISPLAY_ELEMENT_KEYS[number];
export type TabDisplayLayout = 'single' | 'double';
@@ -25,7 +25,7 @@ export type TabDisplayTranslate = (key: string, params?: I18nParams) => string;
const defaultTranslate: TabDisplayTranslate = (key, params) => catalogTranslate('en-US', key, params);
export const TAB_DISPLAY_SECONDARY_DEFAULT_KEYS: TabDisplayElementKey[] = ['kind', 'connection', 'database', 'schema', 'host'];
export const TAB_DISPLAY_SECONDARY_DEFAULT_KEYS: TabDisplayElementKey[] = ['kind', 'connection', 'database', 'schema', 'host', 'group'];
export const TAB_DISPLAY_ELEMENT_META: Record<TabDisplayElementKey, { labelKey: string; descriptionKey: string }> = {
connection: {
@@ -52,6 +52,10 @@ export const TAB_DISPLAY_ELEMENT_META: Record<TabDisplayElementKey, { labelKey:
labelKey: 'app.theme.tab_display.element.host.label',
descriptionKey: 'app.theme.tab_display.element.host.description',
},
group: {
labelKey: 'app.theme.tab_display.element.group.label',
descriptionKey: 'app.theme.tab_display.element.group.description',
},
};
const DEFAULT_SINGLE_TAB_DISPLAY_SNAPSHOT: TabDisplayLayoutSnapshot = {
@@ -276,6 +280,34 @@ export const resolveConnectionHostSummary = (config?: ConnectionConfig): string
return `${hosts[0]} +${hosts.length - 1}`;
};
/**
* Builds a connectionId -> group-name lookup from connection tags.
* A connection belongs to the first tag (in array order) whose
* `connectionIds` contains it, matching the sidebar's ownership
* resolution. The lookup helper returns an empty string for ungrouped connections.
*/
export const buildConnectionGroupNameIndex = (
connectionTags: ConnectionTag[],
): Map<string, string> => {
const map = new Map<string, string>();
connectionTags.forEach((tag) => {
const groupName = String(tag?.name || '').trim();
if (!groupName) return;
tag.connectionIds.forEach((connectionId) => {
const trimmed = String(connectionId || '').trim();
if (trimmed && !map.has(trimmed)) {
map.set(trimmed, groupName);
}
});
});
return map;
};
export const getConnectionGroupName = (
index: Map<string, string>,
connectionId: string | undefined,
): string => index.get(String(connectionId || '').trim()) || '';
const isRedisTab = (tab: TabData): boolean => {
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
};
@@ -539,6 +571,7 @@ const getTabDisplayElementValue = (
tab: TabData,
connection?: SavedConnection,
translate: TabDisplayTranslate = defaultTranslate,
groupName?: string,
): string => {
const rawObjectLabel = getTabRawObjectLabel(tab, translate);
switch (key) {
@@ -557,6 +590,8 @@ const getTabDisplayElementValue = (
return String(tab.schemaName || '').trim() || getSchemaFromTabObjectLabel(rawObjectLabel);
case 'host':
return resolveConnectionHostSummary(connection?.config);
case 'group':
return String(groupName || '').trim();
default:
return '';
}
@@ -589,9 +624,10 @@ const buildTabDisplayParts = (
tab: TabData,
connection?: SavedConnection,
translate: TabDisplayTranslate = defaultTranslate,
groupName?: string,
): TabDisplayPart[] => keys
.map((key) => {
const value = getTabDisplayElementValue(key, tab, connection, translate);
const value = getTabDisplayElementValue(key, tab, connection, translate, groupName);
return {
key,
value,
@@ -605,10 +641,11 @@ export const buildTabDisplayModel = (
connection?: SavedConnection,
settings?: Partial<TabDisplaySettings> | null,
translate: TabDisplayTranslate = defaultTranslate,
groupName?: string,
): TabDisplayModel => {
const sanitized = sanitizeTabDisplaySettings(settings);
const primaryParts = buildTabDisplayParts(sanitized.primaryElements, tab, connection, translate);
const secondaryParts = buildTabDisplayParts(sanitized.secondaryElements, tab, connection, translate);
const primaryParts = buildTabDisplayParts(sanitized.primaryElements, tab, connection, translate, groupName);
const secondaryParts = buildTabDisplayParts(sanitized.secondaryElements, tab, connection, translate, groupName);
const primaryText = primaryParts.map((part) => part.text).join(' ').trim() || buildCompactObjectTabTitle(tab, translate);
const secondaryText = secondaryParts.map((part) => part.text).join('·').trim();
const fullTitle = [primaryText, secondaryText].filter(Boolean).join(' · ');
@@ -627,9 +664,10 @@ export const buildTabDisplayTitle = (
connection?: SavedConnection,
settings?: Partial<TabDisplaySettings> | null,
translate: TabDisplayTranslate = defaultTranslate,
groupName?: string,
): string => {
if (settings) {
return buildTabDisplayModel(tab, connection, settings, translate).fullTitle;
return buildTabDisplayModel(tab, connection, settings, translate, groupName).fullTitle;
}
const connectionName = String(connection?.name || '').trim();

View File

@@ -4106,7 +4106,8 @@ body[data-ui-version="v2"] .gn-v2-tab-label-part-connection {
body[data-ui-version="v2"] .gn-v2-tab-label-part-host,
body[data-ui-version="v2"] .gn-v2-tab-label-part-database,
body[data-ui-version="v2"] .gn-v2-tab-label-part-schema {
body[data-ui-version="v2"] .gn-v2-tab-label-part-schema,
body[data-ui-version="v2"] .gn-v2-tab-label-part-group {
flex: 0 2 auto;
max-width: 112px;
}

View File

@@ -3047,6 +3047,8 @@
"app.theme.tab_display.element.connection.label": "Verbindung",
"app.theme.tab_display.element.database.description": "Aktueller DB- / catalog-Name",
"app.theme.tab_display.element.database.label": "Datenbank",
"app.theme.tab_display.element.group.description": "Name der Gruppe der Verbindung, leer wenn nicht zugeordnet",
"app.theme.tab_display.element.group.label": "Gruppe",
"app.theme.tab_display.element.host.description": "Zusammenfassung der Zieladresse der Verbindung",
"app.theme.tab_display.element.host.label": "Host/IP",
"app.theme.tab_display.element.kind.description": "Typkennzeichen wie SQL / TABLE / VIEW",

View File

@@ -3047,6 +3047,8 @@
"app.theme.tab_display.element.connection.label": "Connection",
"app.theme.tab_display.element.database.description": "Current DB / catalog name",
"app.theme.tab_display.element.database.label": "Database",
"app.theme.tab_display.element.group.description": "Group name of the connection, empty when ungrouped",
"app.theme.tab_display.element.group.label": "Group",
"app.theme.tab_display.element.host.description": "Connection target address summary",
"app.theme.tab_display.element.host.label": "Host/IP",
"app.theme.tab_display.element.kind.description": "Type labels such as SQL / TABLE / VIEW",

View File

@@ -3047,6 +3047,8 @@
"app.theme.tab_display.element.connection.label": "接続名",
"app.theme.tab_display.element.database.description": "現在の DB / catalog 名",
"app.theme.tab_display.element.database.label": "データベース",
"app.theme.tab_display.element.group.description": "接続が属するグループ名。未グループの場合は空",
"app.theme.tab_display.element.group.label": "グループ",
"app.theme.tab_display.element.host.description": "接続先アドレスの要約",
"app.theme.tab_display.element.host.label": "Host/IP",
"app.theme.tab_display.element.kind.description": "SQL / TABLE / VIEW などの種別ラベル",

View File

@@ -3047,6 +3047,8 @@
"app.theme.tab_display.element.connection.label": "Подключение",
"app.theme.tab_display.element.database.description": "Текущее имя DB / catalog",
"app.theme.tab_display.element.database.label": "База данных",
"app.theme.tab_display.element.group.description": "Имя группы подключения, пусто, если не в группе",
"app.theme.tab_display.element.group.label": "Группа",
"app.theme.tab_display.element.host.description": "Краткий адрес целевого подключения",
"app.theme.tab_display.element.host.label": "Host/IP",
"app.theme.tab_display.element.kind.description": "Метки типов, например SQL / TABLE / VIEW",

View File

@@ -3047,6 +3047,8 @@
"app.theme.tab_display.element.connection.label": "连接名",
"app.theme.tab_display.element.database.description": "当前 DB / catalog 名称",
"app.theme.tab_display.element.database.label": "数据库",
"app.theme.tab_display.element.group.description": "连接所属的分组名称,未分组时为空",
"app.theme.tab_display.element.group.label": "分组",
"app.theme.tab_display.element.host.description": "连接目标地址摘要",
"app.theme.tab_display.element.host.label": "Host/IP",
"app.theme.tab_display.element.kind.description": "SQL / TABLE / VIEW 等类型标签",

View File

@@ -3047,6 +3047,8 @@
"app.theme.tab_display.element.connection.label": "連線名稱",
"app.theme.tab_display.element.database.description": "目前 DB / catalog 名稱",
"app.theme.tab_display.element.database.label": "資料庫",
"app.theme.tab_display.element.group.description": "連線所屬的分組名稱,未分組時為空",
"app.theme.tab_display.element.group.label": "分組",
"app.theme.tab_display.element.host.description": "連線目標位址摘要",
"app.theme.tab_display.element.host.label": "Host/IP",
"app.theme.tab_display.element.kind.description": "SQL / TABLE / VIEW 等類型標籤",