mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
✨ 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:
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 などの種別ラベル",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 等类型标签",
|
||||
|
||||
@@ -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 等類型標籤",
|
||||
|
||||
Reference in New Issue
Block a user