diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index e5f5008e..450091a5 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -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 = React.memo(({ 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 = React.memo(({ 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(null); const [v2TabWidth, setV2TabWidth] = useState(V2_WORKBENCH_TAB_MAX_WIDTH); @@ -1052,7 +1059,7 @@ const TabManager: React.FC = React.memo(({ 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 = React.memo(({ 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) => ( @@ -1255,7 +1262,7 @@ const TabManager: React.FC = React.memo(({ 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 = React.memo(({ onF closable: !isV2Ui, children: , }; - }), [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), diff --git a/frontend/src/utils/tabDisplay.test.ts b/frontend/src/utils/tabDisplay.test.ts index 32036d14..60909d2a 100644 --- a/frontend/src/utils/tabDisplay.test.ts +++ b/frontend/src/utils/tabDisplay.test.ts @@ -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', () => { diff --git a/frontend/src/utils/tabDisplay.ts b/frontend/src/utils/tabDisplay.ts index e871e14d..1abfbc79 100644 --- a/frontend/src/utils/tabDisplay.ts +++ b/frontend/src/utils/tabDisplay.ts @@ -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 = { connection: { @@ -52,6 +52,10 @@ export const TAB_DISPLAY_ELEMENT_META: Record 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 => { + const map = new Map(); + 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, + 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 | 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 | 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(); diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index f54784ee..8a9ce7d1 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -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; } diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index c8282455..93e81776 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -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", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 73ee8fcb..44c0dc40 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -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", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 812b3d72..e2a49a1c 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -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 などの種別ラベル", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 92490496..e7969b3f 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -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", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index ef772806..04c1ed12 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -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 等类型标签", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 6148336a..49f0555d 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -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 等類型標籤",