diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a7757bb..57be88f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,17 @@ import { sanitizeDataTableFontSize, sanitizeSidebarTreeFontSize, } from './utils/dataGridDisplay'; +import { + TAB_DISPLAY_SECONDARY_DEFAULT_KEYS, + TAB_DISPLAY_ELEMENT_META, + applyTabDisplaySettingsPatch, + resolveTabDisplayElementOrder, + sanitizeTabDisplaySettings, + switchTabDisplayLayout, + type TabDisplayElementKey, + type TabDisplayLayout, + type TabDisplaySettings, +} from './utils/tabDisplay'; import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics'; import { resolveAboutDisplayVersion } from './utils/appVersionDisplay'; @@ -273,6 +284,90 @@ function App() { const effectiveSidebarTreeFontSize = sidebarTreeFontSizeFollowsGlobal ? effectiveFontSize : (sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize) ?? effectiveFontSize); + const tabDisplaySettings = useMemo( + () => sanitizeTabDisplaySettings(appearance.tabDisplay), + [appearance.tabDisplay], + ); + const tabDisplayElementOrder = useMemo( + () => resolveTabDisplayElementOrder(tabDisplaySettings), + [tabDisplaySettings], + ); + const visibleTabDisplayElementKeys = useMemo( + () => new Set([ + ...tabDisplaySettings.primaryElements, + ...tabDisplaySettings.secondaryElements, + ]), + [tabDisplaySettings], + ); + const setTabDisplaySettings = useCallback((settings: Partial) => { + setAppearance({ + tabDisplay: applyTabDisplaySettingsPatch(tabDisplaySettings, settings), + }); + }, [setAppearance, tabDisplaySettings]); + const setTabDisplayLayout = useCallback((layout: TabDisplayLayout) => { + if (layout === tabDisplaySettings.layout) return; + setAppearance({ + tabDisplay: switchTabDisplayLayout(tabDisplaySettings, layout), + }); + }, [setAppearance, tabDisplaySettings]); + const updateTabDisplayElementVisibility = useCallback((key: TabDisplayElementKey, checked: boolean) => { + setFocusedTabDisplayElementKey(key); + const removeKey = (keys: TabDisplayElementKey[]) => keys.filter((item) => item !== key); + if (!checked) { + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements: removeKey(tabDisplaySettings.primaryElements), + secondaryElements: removeKey(tabDisplaySettings.secondaryElements), + }); + return; + } + + const primaryElements = removeKey(tabDisplaySettings.primaryElements); + const secondaryElements = removeKey(tabDisplaySettings.secondaryElements); + if (tabDisplaySettings.layout === 'double' && TAB_DISPLAY_SECONDARY_DEFAULT_KEYS.includes(key)) { + secondaryElements.push(key); + } else { + primaryElements.push(key); + } + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements, + secondaryElements, + }); + }, [setTabDisplaySettings, tabDisplaySettings]); + const moveTabDisplayElement = useCallback((key: TabDisplayElementKey, offset: -1 | 1) => { + setFocusedTabDisplayElementKey(key); + const moveWithin = (keys: TabDisplayElementKey[]) => { + const index = keys.indexOf(key); + if (index < 0) return keys; + const nextIndex = index + offset; + if (nextIndex < 0 || nextIndex >= keys.length) return keys; + const next = [...keys]; + [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; + return next; + }; + + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements: moveWithin(tabDisplaySettings.primaryElements), + secondaryElements: moveWithin(tabDisplaySettings.secondaryElements), + }); + }, [setTabDisplaySettings, tabDisplaySettings]); + const setTabDisplayElementRow = useCallback((key: TabDisplayElementKey, row: 'primary' | 'secondary') => { + setFocusedTabDisplayElementKey(key); + const primaryElements = tabDisplaySettings.primaryElements.filter((item) => item !== key); + const secondaryElements = tabDisplaySettings.secondaryElements.filter((item) => item !== key); + if (row === 'primary') { + primaryElements.push(key); + } else { + secondaryElements.push(key); + } + setTabDisplaySettings({ + layout: tabDisplaySettings.layout, + primaryElements, + secondaryElements, + }); + }, [setTabDisplaySettings, tabDisplaySettings]); const resolvedUiFontFamily = resolveUIFontFamily(appearance.customUIFontFamily); const resolvedMonoFontFamily = resolveMonoFontFamily(appearance.customMonoFontFamily); const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle'); @@ -312,6 +407,7 @@ function App() { const [isSecurityUpdateProgressOpen, setIsSecurityUpdateProgressOpen] = useState(false); const [securityUpdateProgressStage, setSecurityUpdateProgressStage] = useState('正在检查已保存配置'); const [securityUpdateRepairSource, setSecurityUpdateRepairSource] = useState(null); + const [focusedTabDisplayElementKey, setFocusedTabDisplayElementKey] = useState(null); const [focusedAIProviderId, setFocusedAIProviderId] = useState(undefined); const [connectionPackageDialog, setConnectionPackageDialog] = useState(() => createClosedConnectionPackageDialogState()); const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState(null); @@ -2082,6 +2178,8 @@ function App() { const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); + const tabDisplaySettingsPanelRef = useRef(null); + const [tabDisplaySettingsFocusRequest, setTabDisplaySettingsFocusRequest] = useState(0); useEffect(() => { if (!isThemeModalOpen || themeModalSection !== 'appearance') { return; @@ -2133,6 +2231,16 @@ function App() { }; }, [isThemeModalOpen, themeModalSection]); + useEffect(() => { + if (!isThemeModalOpen || themeModalSection !== 'appearance' || tabDisplaySettingsFocusRequest === 0) { + return; + } + const timer = window.setTimeout(() => { + tabDisplaySettingsPanelRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }, 80); + return () => window.clearTimeout(timer); + }, [isThemeModalOpen, themeModalSection, tabDisplaySettingsFocusRequest]); + const shortcutConflictMap = useMemo(() => { const map: Partial> = {}; for (const action of SHORTCUT_ACTION_ORDER) { @@ -2867,6 +2975,19 @@ function App() { }; }, []); + useEffect(() => { + const handleOpenTabDisplaySettingsEvent = () => { + setIsSettingsModalOpen(false); + setThemeModalSection('appearance'); + setIsThemeModalOpen(true); + setTabDisplaySettingsFocusRequest((current) => current + 1); + }; + window.addEventListener('gonavi:open-tab-display-settings', handleOpenTabDisplaySettingsEvent as EventListener); + return () => { + window.removeEventListener('gonavi:open-tab-display-settings', handleOpenTabDisplaySettingsEvent as EventListener); + }; + }, []); + useEffect(() => { const handleCreateQueryTabEvent = () => { handleNewQuery(); @@ -4218,6 +4339,181 @@ function App() { +
+
+
+
Tab 标签展示
+
+ 自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序;双行模式可把上下文放到副行。 +
+
+ setTabDisplayLayout(value as TabDisplayLayout)} + /> +
+
+ {tabDisplayElementOrder.map((key) => { + const meta = TAB_DISPLAY_ELEMENT_META[key]; + const checked = visibleTabDisplayElementKeys.has(key); + const row = tabDisplaySettings.secondaryElements.includes(key) ? 'secondary' : 'primary'; + const currentRowElements = row === 'secondary' + ? tabDisplaySettings.secondaryElements + : tabDisplaySettings.primaryElements; + const indexInRow = currentRowElements.indexOf(key); + const canMoveUp = checked && indexInRow > 0; + const canMoveDown = checked && indexInRow >= 0 && indexInRow < currentRowElements.length - 1; + const isFocused = focusedTabDisplayElementKey === key; + return ( +
setFocusedTabDisplayElementKey(key)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setFocusedTabDisplayElementKey(key); + } + }} + style={{ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: 10, + alignItems: 'center', + padding: '9px 10px', + borderRadius: 10, + border: `1px solid ${isFocused + ? (darkMode ? 'rgba(255,214,102,0.54)' : 'rgba(24,144,255,0.54)') + : (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`, + boxShadow: isFocused + ? (darkMode ? '0 0 0 2px rgba(255,214,102,0.14)' : '0 0 0 2px rgba(24,144,255,0.12)') + : 'none', + background: isFocused + ? (darkMode ? 'linear-gradient(90deg, rgba(255,214,102,0.12) 0%, rgba(255,255,255,0.045) 100%)' : 'linear-gradient(90deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.78) 100%)') + : checked + ? (darkMode ? 'rgba(255,255,255,0.045)' : 'rgba(255,255,255,0.62)') + : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(16,24,40,0.025)'), + cursor: 'pointer', + transition: 'border-color 140ms ease, box-shadow 140ms ease, background 140ms ease', + }} + > +
+ + {checked && indexInRow >= 0 ? indexInRow + 1 : '-'} + + event.stopPropagation()} + onChange={(nextChecked) => updateTabDisplayElementVisibility(key, nextChecked)} + /> +
+
+ {meta.label} + {isFocused ? ( + + 当前 + + ) : null} + {checked && tabDisplaySettings.layout === 'double' ? ( + + {row === 'secondary' ? '副行' : '主行'} + + ) : null} +
+
{meta.description}
+
+
+
+ {tabDisplaySettings.layout === 'double' && checked ? ( + setTabDisplayElementRow(key, value as 'primary' | 'secondary')} + onClick={(event) => event.stopPropagation()} + /> + ) : null} + + +
+
+ ); + })} +
+
+ 当前预览:{tabDisplaySettings.layout === 'double' ? '主行 ' : ''} + {tabDisplaySettings.primaryElements.map((key) => TAB_DISPLAY_ELEMENT_META[key].label).join(' / ') || '默认标签'} + {tabDisplaySettings.layout === 'double' && tabDisplaySettings.secondaryElements.length > 0 + ? `,副行 ${tabDisplaySettings.secondaryElements.map((key) => TAB_DISPLAY_ELEMENT_META[key].label).join(' / ')}` + : ''} + {focusedTabDisplayElementKey + ? `;当前选中 ${TAB_DISPLAY_ELEMENT_META[focusedTabDisplayElementKey].label}` + : ''} +
+
透明与模糊效果
diff --git a/frontend/src/components/TabManager.hover.test.tsx b/frontend/src/components/TabManager.hover.test.tsx index 24551b5..420ba74 100644 --- a/frontend/src/components/TabManager.hover.test.tsx +++ b/frontend/src/components/TabManager.hover.test.tsx @@ -6,11 +6,13 @@ import { describe, expect, it, vi } from 'vitest'; import { TAB_WORKBENCH_CLASS_NAME, resolveTabHoverOpen, + resolveTabHoverTitle, shouldShowV2ConnectionLabel, TabHoverInfo, stopTabHoverDragPropagation, } from './TabManager'; import type { TabData } from '../types'; +import { buildTabDisplayModel } from '../utils/tabDisplay'; describe('TabManager hover info', () => { it('memoizes the tab workbench so parent-only modal state does not repaint open tabs', () => { @@ -85,6 +87,34 @@ describe('TabManager hover info', () => { expect(markup).toContain('db2'); }); + it('keeps v2 hover title focused on the tab object instead of appending secondary display fields', () => { + const tab: TabData = { + id: 'overview-1', + title: '表概览 - front_end_sys', + type: 'table-overview', + connectionId: 'conn-1', + dbName: 'front_end_sys', + }; + const displayModel = buildTabDisplayModel(tab, { + id: 'conn-1', + name: '开发240', + config: { + type: 'mysql', + host: '192.168.1.240', + port: 3306, + user: 'root', + database: 'front_end_sys', + }, + }, { + layout: 'double', + primaryElements: ['object', 'kind'], + secondaryElements: ['connection', 'database'], + }); + + expect(displayModel.fullTitle).toContain('[开发240]'); + expect(resolveTabHoverTitle(displayModel, displayModel.fullTitle)).toBe('表概览 - front_end_sys'); + }); + it('stops hover card pointer events from reaching tab drag listeners without blocking text selection', () => { const event = { preventDefault: vi.fn(), @@ -103,6 +133,13 @@ describe('TabManager hover info', () => { expect(resolveTabHoverOpen(false, true)).toBe(false); }); + it('opens tab display settings from the v2 tab context menu', () => { + const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("new CustomEvent('gonavi:open-tab-display-settings')"); + expect(source).toContain("if (typeof window === 'undefined')"); + }); + it('hides the v2 gray connection suffix when the title already carries the same prefix', () => { expect(shouldShowV2ConnectionLabel('[本地] videos', '本地')).toBe(false); expect(shouldShowV2ConnectionLabel('[缓存 | 10.0.0.8] db2', '缓存')).toBe(false); @@ -118,6 +155,29 @@ describe('TabManager hover info', () => { expect(source).not.toContain('resolveConnectionAccentColor'); }); + it('renders tab labels from appearance tab display settings', () => { + const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('buildTabDisplayModel(tab, connection, appearance.tabDisplay)'); + expect(source).toContain('displayModel={displayModel}'); + expect(source).toContain('displayModel.primaryParts.map(renderV2TabDisplayPart)'); + expect(source).toContain("if (part.key === 'kind')"); + expect(source).toContain('className="gn-v2-tab-kind"'); + expect(source).toContain('hasDoubleLineTabLabel'); + expect(source).toContain('gn-v2-main-tabs-double'); + expect(source).toContain('showSecondaryLine'); + expect(source).toContain('gn-v2-tab-label-secondary'); + expect(source).toContain('gn-v2-tab-label-rich'); + expect(source).toContain('gn-v2-tab-label-double'); + expect(source).toContain('gn-v2-tab-label-main tab-title-text'); + expect(source).toContain("key: 'tab-display-settings'"); + expect(source).toContain('label: \'标签设置\''); + expect(source).toContain('icon: '); + expect(source).toContain('onClick: openTabDisplaySettings'); + expect(source).toContain("rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}"); + expect(source).not.toContain('gn-v2-main-tabs-rich'); + }); + it('wires hover card tab-switch and drag-blocking handlers with selectable text styles', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); @@ -139,7 +199,26 @@ describe('TabManager hover info', () => { expect(source).toMatch(/\.gn-v2-tab-hover-card \{[^}]*cursor: text;[^}]*user-select: text;/s); expect(source).toContain("--gn-v2-tab-hover-grid-columns: 56px minmax(0, 1fr);"); expect(source).toMatch(/\.gn-v2-tab-hover-head \{[^}]*display: grid;[^}]*grid-template-columns: var\(--gn-v2-tab-hover-grid-columns\);/s); + expect(source).toMatch(/\.gn-v2-tab-hover-head > strong \{[^}]*overflow-wrap: anywhere;[^}]*white-space: normal;/s); expect(source).toMatch(/\.gn-v2-tab-hover-row \{[^}]*grid-template-columns: var\(--gn-v2-tab-hover-grid-columns\);/s); expect(source).toMatch(/\.gn-v2-tab-hover-card \* \{[^}]*user-select: text;/s); }); + + it('guards closing opened SQL file tabs with save confirmation', () => { + const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('ReadSQLFile(filePath)'); + expect(source).toContain("getSQLFileTabDraft(tab.id, String(tab.query ?? ''))"); + expect(source).toContain('hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))'); + expect(source).toContain("title: '保存 SQL 文件修改?'"); + expect(source).toContain("okText: '保存并关闭'"); + expect(source).toContain('不保存'); + expect(source).toContain('WriteSQLFile(filePath, draft)'); + expect(source).toContain('clearSQLFileTabDraft(tab.id)'); + expect(source).toContain('closeTabsWithSQLFilePrompt([id], () => closeTab(id))'); + expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id))'); + expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id))'); + expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id))'); + expect(source).toContain('closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs())'); + }); }); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 467c07c..afee795 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -1,6 +1,6 @@ -import React, { useMemo, useRef, useState } from 'react'; -import { Button, Dropdown, Tabs, Tooltip } from 'antd'; -import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Button, Dropdown, message, Modal, Tabs, Tooltip } from 'antd'; +import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons'; import type { MenuProps, TabsProps } from 'antd'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; @@ -23,22 +23,23 @@ import JVMAuditViewer from './JVMAuditViewer'; import JVMDiagnosticConsole from './JVMDiagnosticConsole'; import JVMMonitoringDashboard from './JVMMonitoringDashboard'; import type { TabData } from '../types'; -import { buildTabDisplayTitle } from '../utils/tabDisplay'; -import { resolveConnectionHostSummary } from '../utils/tabDisplay'; +import { + buildTabDisplayModel, + getTabDisplayKindLabel, + resolveConnectionHostSummary, + type TabDisplayPart, + type TabDisplayModel, +} from '../utils/tabDisplay'; +import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App'; +import { + getSQLFileTabPath, + hasSQLFileTabUnsavedChanges, + isSQLFileQueryTab, + normalizeSQLFileReadContent, +} from '../utils/sqlFileTabDirty'; +import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; -const getTabKindLabel = (tab: TabData): string => { - if (tab.type === 'query') return 'SQL'; - if (tab.type === 'table') return 'TABLE'; - if (tab.type === 'design') return 'DESIGN'; - if (tab.type === 'table-overview') return 'DB'; - if (tab.type.startsWith('redis')) return 'REDIS'; - if (tab.type.startsWith('jvm')) return 'JVM'; - if (tab.type === 'trigger') return 'TRG'; - if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? 'MV' : 'VIEW'; - if (tab.type === 'event-def') return 'EVT'; - if (tab.type === 'routine-def') return 'FUNC'; - return 'TAB'; -}; +const getTabKindLabel = getTabDisplayKindLabel; export const TAB_WORKBENCH_CLASS_NAME = 'tab-workbench'; @@ -74,6 +75,21 @@ const getTabObjectLabel = (tab: TabData): string => { return ''; }; +const getCloseOtherTabIds = (tabs: TabData[], id: string): string[] => + tabs.filter((tab) => tab.id !== id).map((tab) => tab.id); + +const getCloseTabsToLeftIds = (tabs: TabData[], id: string): string[] => { + const index = tabs.findIndex((tab) => tab.id === id); + if (index <= 0) return []; + return tabs.slice(0, index).map((tab) => tab.id); +}; + +const getCloseTabsToRightIds = (tabs: TabData[], id: string): string[] => { + const index = tabs.findIndex((tab) => tab.id === id); + if (index < 0 || index >= tabs.length - 1) return []; + return tabs.slice(index + 1).map((tab) => tab.id); +}; + export const stopTabHoverDragPropagation = (event: React.SyntheticEvent) => { event.stopPropagation(); }; @@ -81,6 +97,13 @@ export const stopTabHoverDragPropagation = (event: React.SyntheticEvent isHoverInfoOpen && !isTabMenuOpen; +export const openTabDisplaySettings = () => { + if (typeof window === 'undefined') { + return; + } + window.dispatchEvent(new CustomEvent('gonavi:open-tab-display-settings')); +}; + export const shouldShowV2ConnectionLabel = (displayTitle: string, connectionLabel?: string): boolean => { const normalizedConnectionLabel = String(connectionLabel || '').trim(); if (!normalizedConnectionLabel) { @@ -97,8 +120,28 @@ export const shouldShowV2ConnectionLabel = (displayTitle: string, connectionLabe return !prefixedConnectionPattern.test(normalizedDisplayTitle); }; +export const resolveTabHoverTitle = (displayModel: TabDisplayModel | undefined, fallbackTitle: string): string => { + if (!displayModel) { + return fallbackTitle; + } + + const objectPart = [...displayModel.primaryParts, ...displayModel.secondaryParts] + .find((part) => part.key === 'object'); + if (objectPart?.text) { + return objectPart.text; + } + + const primaryText = displayModel.primaryParts + .filter((part) => part.key !== 'kind') + .map((part) => part.text) + .join(' ') + .trim(); + return primaryText || displayModel.primaryText || fallbackTitle; +}; + type TabHoverInfoProps = { tab: TabData; + displayModel?: TabDisplayModel; displayTitle: string; connectionLabel?: string; hostSummary?: string; @@ -106,16 +149,22 @@ type TabHoverInfoProps = { export const TabHoverInfo: React.FC = ({ tab, + displayModel, displayTitle, connectionLabel, hostSummary, }) => { const objectLabel = getTabObjectLabel(tab); + const hoverTitle = resolveTabHoverTitle(displayModel, displayTitle); + const schemaPart = displayModel + ? [...displayModel.primaryParts, ...displayModel.secondaryParts].find((part) => part.key === 'schema') + : undefined; const rows = [ ['类型', getTabKindTooltipLabel(tab)], ['连接', connectionLabel || '未绑定连接'], ['Host', hostSummary || '未配置'], ['数据库', tab.dbName || '未指定'], + ['Schema', schemaPart?.value], ['对象', objectLabel], ].filter(([, value]) => Boolean(value)); @@ -139,7 +188,7 @@ export const TabHoverInfo: React.FC = ({ >
{getTabKindLabel(tab)} - {displayTitle} + {hoverTitle}
{rows.map(([label, value]) => ( @@ -155,6 +204,7 @@ export const TabHoverInfo: React.FC = ({ type SortableTabLabelProps = { tab: TabData; + displayModel: TabDisplayModel; displayTitle: string; menuItems: MenuProps['items']; connectionLabel?: string; @@ -163,8 +213,24 @@ type SortableTabLabelProps = { onClose?: () => void; }; +const renderV2TabDisplayPart = (part: TabDisplayPart) => { + if (part.key === 'kind') { + return ( + + {part.text} + + ); + } + return ( + + {part.text} + + ); +}; + const SortableTabLabel: React.FC = ({ tab, + displayModel, displayTitle, menuItems, connectionLabel, @@ -190,17 +256,30 @@ const SortableTabLabel: React.FC = ({ setIsHoverInfoOpen(open && !isTabMenuOpen); }; + const tabDisplayPartCount = displayModel.primaryParts.length + displayModel.secondaryParts.length; + const showSecondaryLine = isV2Ui && displayModel.layout === 'double' && Boolean(displayModel.secondaryText); const labelNode = ( = 4 ? ' gn-v2-tab-label-rich' : ''}`} onContextMenu={handleTabLabelContextMenu} title={isV2Ui ? undefined : displayTitle} > - {isV2Ui ? {getTabKindLabel(tab)} : null} - {displayTitle} - {isV2Ui && shouldShowV2ConnectionLabel(displayTitle, connectionLabel) ? ( - {connectionLabel} - ) : null} + {isV2Ui ? ( + + + {displayModel.primaryParts.length > 0 + ? displayModel.primaryParts.map(renderV2TabDisplayPart) + : displayModel.primaryText} + + {showSecondaryLine ? ( + + {displayModel.secondaryText} + + ) : null} + + ) : ( + {displayTitle} + )} {isV2Ui && onClose ? ( + + + + ), + onOk: async () => { + try { + for (const { tab, draft } of dirtyTabs) { + const filePath = getSQLFileTabPath(tab); + if (!filePath) continue; + const res = await WriteSQLFile(filePath, draft); + if (!res.success) { + throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`); + } + } + message.success('SQL 文件已保存'); + closeConfirmedTabsAndClearDrafts(); + } catch (error) { + message.error(error instanceof Error ? error.message : String(error)); + throw error; + } + }, + }); + destroyConfirm = confirmRef.destroy; + }, []); + + const closeTabsWithSQLFilePrompt = useCallback((targetIds: string[], closeConfirmedTabs: () => void) => { + const uniqueIds = Array.from(new Set(targetIds.map((id) => String(id || '').trim()).filter(Boolean))); + if (uniqueIds.length === 0) return; + const dedupeKey = uniqueIds.slice().sort().join('\n'); + if (pendingCloseTabIdsRef.current.has(dedupeKey)) return; + pendingCloseTabIdsRef.current.add(dedupeKey); + const targetTabs = tabs.filter((tab) => uniqueIds.includes(tab.id)); + void requestCloseSQLFileTabs(targetTabs, closeConfirmedTabs).finally(() => { + pendingCloseTabIdsRef.current.delete(dedupeKey); + }); + }, [requestCloseSQLFileTabs, tabs]); + const onEdit = (targetKey: React.MouseEvent | React.KeyboardEvent | string, action: 'add' | 'remove') => { if (action === 'remove') { - closeTab(targetKey as string); + const id = String(targetKey || ''); + closeTabsWithSQLFilePrompt([id], () => closeTab(id)); } }; @@ -428,6 +617,13 @@ const TabManager: React.FC = React.memo(() => { }, [tabs, activeTabId, addTab, setActiveTab, connections]); const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); + const hasDoubleLineTabLabel = useMemo(() => ( + tabs.some((tab) => { + const connection = connections.find((conn) => conn.id === tab.connectionId); + const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay); + return displayModel.layout === 'double' && Boolean(displayModel.secondaryText); + }) + ), [appearance.tabDisplay, connections, tabs]); const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => ( @@ -437,35 +633,43 @@ const TabManager: React.FC = React.memo(() => { const items = useMemo(() => tabs.map((tab, index) => { const connection = connections.find((conn) => conn.id === tab.connectionId); - const displayTitle = buildTabDisplayTitle(tab, connection); + const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay); + const displayTitle = displayModel.fullTitle; const hostSummary = resolveConnectionHostSummary(connection?.config); const tabIsActive = tab.id === activeTabId; const menuItems: MenuProps['items'] = [ + { + key: 'tab-display-settings', + icon: , + label: '标签设置', + onClick: openTabDisplaySettings, + }, + { type: 'divider' }, { key: 'close-other', label: '关闭其他页', disabled: tabs.length <= 1, - onClick: () => closeOtherTabs(tab.id), + onClick: () => closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id)), }, { key: 'close-left', label: '关闭左侧', disabled: index === 0, - onClick: () => closeTabsToLeft(tab.id), + onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id)), }, { key: 'close-right', label: '关闭右侧', disabled: index === tabs.length - 1, - onClick: () => closeTabsToRight(tab.id), + onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id)), }, { type: 'divider' }, { key: 'close-all', label: '关闭所有', disabled: tabs.length === 0, - onClick: () => closeAllTabs(), + onClick: () => closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs()), }, ]; @@ -473,19 +677,20 @@ const TabManager: React.FC = React.memo(() => { label: ( closeTab(tab.id)} + onClose={() => closeTabsWithSQLFilePrompt([tab.id], () => closeTab(tab.id))} /> ), key: tab.id, closable: !isV2Ui, children: , }; - }), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, isV2Ui]); + }), [tabs, connections, appearance.tabDisplay, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, isV2Ui]); const handleOpenConnectionModal = () => { const target = document.querySelector('[data-gonavi-create-connection-action="true"]'); @@ -693,13 +898,12 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active { } body[data-ui-version='v2'] .gn-v2-tab-hover-head > strong { min-width: 0; - overflow: hidden; + overflow-wrap: anywhere; color: var(--gn-fg-1); font-size: var(--gn-font-size-sm, 12px); font-weight: 700; line-height: 18px; - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; } body[data-ui-version='v2'] .gn-v2-tab-hover-rows { display: grid; @@ -736,7 +940,7 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active { > { diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 81b8097..48ab48b 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -77,6 +77,11 @@ describe('store appearance persistence', () => { expect(appearance.sidebarTreeFontSizeFollowGlobal).toBe(true); expect(appearance.customUIFontFamily).toBeNull(); expect(appearance.customMonoFontFamily).toBeNull(); + expect(appearance.tabDisplay).toEqual({ + layout: 'single', + primaryElements: ['connection', 'kind', 'object'], + secondaryElements: [], + }); }); it('persists DataGrid appearance settings and restores them after reload', async () => { @@ -119,6 +124,83 @@ describe('store appearance persistence', () => { expect(appearance.customMonoFontFamily).toBeNull(); }); + it('persists tab display appearance settings and sanitizes invalid elements', async () => { + const { useStore } = await importStore(); + + useStore.getState().setAppearance({ + tabDisplay: { + layout: 'double', + primaryElements: ['kind', 'object', 'invalid' as never, 'object'], + secondaryElements: ['connection', 'host', 'schema', 'kind'], + }, + }); + + const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.appearance.tabDisplay).toEqual({ + layout: 'double', + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'host', 'schema'], + }); + + vi.resetModules(); + const reloaded = await importStore(); + const appearance = reloaded.useStore.getState().appearance; + + expect(appearance.tabDisplay).toEqual({ + layout: 'double', + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'host', 'schema'], + }); + }); + + it('persists independent single-line and double-line tab display snapshots', async () => { + const { useStore } = await importStore(); + + useStore.getState().setAppearance({ + tabDisplay: { + layout: 'double', + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database'], + single: { + primaryElements: ['object', 'host'], + secondaryElements: [], + }, + double: { + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database'], + }, + }, + }); + + const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.appearance.tabDisplay).toEqual({ + layout: 'double', + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database'], + single: { + primaryElements: ['object', 'host'], + secondaryElements: [], + }, + double: { + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database'], + }, + }); + + vi.resetModules(); + const reloaded = await importStore(); + const appearance = reloaded.useStore.getState().appearance; + + expect(appearance.tabDisplay.single).toEqual({ + primaryElements: ['object', 'host'], + secondaryElements: [], + }); + expect(appearance.tabDisplay.double).toEqual({ + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database'], + }); + }); + it('does not clear persisted legacy connections during hydration migration', async () => { storage.setItem('lite-db-storage', JSON.stringify({ state: { @@ -688,8 +770,6 @@ describe('store appearance persistence', () => { id: 'ext-1', name: 'scripts', path: 'D:/sql/scripts', - connectionId: 'conn-1', - dbName: 'demo', createdAt: 1, }); @@ -699,8 +779,6 @@ describe('store appearance persistence', () => { id: 'ext-1', name: 'scripts', path: 'D:/sql/scripts', - connectionId: 'conn-1', - dbName: 'demo', createdAt: 1, }, ]); @@ -709,6 +787,14 @@ describe('store appearance persistence', () => { state: { externalSQLDirectories: [ persisted.state.externalSQLDirectories[0], + { + id: 'legacy-ext-1', + name: 'legacy duplicate', + path: 'D:\\sql\\scripts', + connectionId: 'conn-1', + dbName: 'demo', + createdAt: 2, + }, { path: '', name: 'broken' }, ], }, @@ -722,8 +808,6 @@ describe('store appearance persistence', () => { id: 'ext-1', name: 'scripts', path: 'D:/sql/scripts', - connectionId: 'conn-1', - dbName: 'demo', createdAt: 1, }, ]); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 8ea9057..34b71f9 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -47,6 +47,11 @@ import { resolveOceanBaseProtocolFromQueryText, } from "./utils/oceanBaseProtocol"; import { sanitizeFontFamilyInput } from "./utils/fontFamilies"; +import { + DEFAULT_TAB_DISPLAY_SETTINGS, + sanitizeTabDisplaySettings, + type TabDisplaySettings, +} from "./utils/tabDisplay"; export interface AppearanceSettings extends DataGridDisplaySettings { uiVersion: "legacy" | "v2"; @@ -56,6 +61,7 @@ export interface AppearanceSettings extends DataGridDisplaySettings { useNativeMacWindowControls: boolean; customUIFontFamily: string | null; customMonoFontFamily: string | null; + tabDisplay: TabDisplaySettings; } export const DEFAULT_APPEARANCE: AppearanceSettings = { @@ -66,6 +72,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = { useNativeMacWindowControls: false, customUIFontFamily: null, customMonoFontFamily: null, + tabDisplay: DEFAULT_TAB_DISPLAY_SETTINGS, ...DEFAULT_DATA_GRID_DISPLAY_SETTINGS, }; const DEFAULT_UI_SCALE = 1.0; @@ -1313,13 +1320,17 @@ const sanitizeExternalSQLDirectories = ( ): ExternalSQLDirectory[] => { if (!Array.isArray(value)) return []; const result: ExternalSQLDirectory[] = []; + const seenPaths = new Set(); value.forEach((entry, index) => { if (!entry || typeof entry !== "object") return; const raw = entry as Record; const path = toTrimmedString(raw.path); + if (!path) return; + const normalizedPath = path.replace(/\\/g, "/").toLowerCase(); + if (seenPaths.has(normalizedPath)) return; + seenPaths.add(normalizedPath); const connectionId = toTrimmedString(raw.connectionId); const dbName = toTrimmedString(raw.dbName); - if (!path || !connectionId || !dbName) return; const fallbackName = path.split(/[\\/]/).filter(Boolean).pop() || `SQL目录-${index + 1}`; result.push({ @@ -1330,8 +1341,8 @@ const sanitizeExternalSQLDirectories = ( ) || buildExternalSQLDirectoryId(connectionId, dbName, path), name: toTrimmedString(raw.name, fallbackName) || fallbackName, path, - connectionId, - dbName, + ...(connectionId ? { connectionId } : {}), + ...(dbName ? { dbName } : {}), createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(), @@ -1628,6 +1639,7 @@ const sanitizeAppearance = ( : DEFAULT_APPEARANCE.useNativeMacWindowControls, customUIFontFamily: sanitizeFontFamilyInput(appearance.customUIFontFamily), customMonoFontFamily: sanitizeFontFamilyInput(appearance.customMonoFontFamily), + tabDisplay: sanitizeTabDisplaySettings(appearance.tabDisplay), showDataTableVerticalBorders: dataGridDisplaySettings.showDataTableVerticalBorders, dataTableDensity: dataGridDisplaySettings.dataTableDensity, @@ -2547,11 +2559,11 @@ export const useStore = create()( saveExternalSQLDirectory: (directory) => set((state) => { const path = toTrimmedString(directory.path); - const connectionId = toTrimmedString(directory.connectionId); - const dbName = toTrimmedString(directory.dbName); - if (!path || !connectionId || !dbName) { + if (!path) { return state; } + const connectionId = toTrimmedString(directory.connectionId); + const dbName = toTrimmedString(directory.dbName); const nextDirectory: ExternalSQLDirectory = { id: toTrimmedString( @@ -2564,18 +2576,17 @@ export const useStore = create()( path.split(/[\\/]/).filter(Boolean).pop() || "SQL目录", ) || "SQL目录", path, - connectionId, - dbName, + ...(connectionId ? { connectionId } : {}), + ...(dbName ? { dbName } : {}), createdAt: Number.isFinite(Number(directory.createdAt)) ? Number(directory.createdAt) : Date.now(), }; + const nextPathKey = path.replace(/\\/g, "/").toLowerCase(); const existingIndex = state.externalSQLDirectories.findIndex( (item) => item.id === nextDirectory.id || - (item.connectionId === nextDirectory.connectionId && - item.dbName === nextDirectory.dbName && - item.path === nextDirectory.path), + item.path.replace(/\\/g, "/").toLowerCase() === nextPathKey, ); if (existingIndex === -1) { return { diff --git a/frontend/src/utils/sqlFileTabDirty.test.ts b/frontend/src/utils/sqlFileTabDirty.test.ts new file mode 100644 index 0000000..83250eb --- /dev/null +++ b/frontend/src/utils/sqlFileTabDirty.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSQLFileTabPath, + hasSQLFileTabUnsavedChanges, + isSQLFileQueryTab, + normalizeSQLFileReadContent, +} from './sqlFileTabDirty'; + +describe('sqlFileTabDirty', () => { + it('only treats query tabs with filePath as SQL file tabs', () => { + expect(isSQLFileQueryTab({ type: 'query', filePath: '/tmp/a.sql' })).toBe(true); + expect(isSQLFileQueryTab({ type: 'query', filePath: ' ' })).toBe(false); + expect(isSQLFileQueryTab({ type: 'table', filePath: '/tmp/a.sql' } as any)).toBe(false); + expect(getSQLFileTabPath({ type: 'query', filePath: ' /tmp/a.sql ' })).toBe('/tmp/a.sql'); + }); + + it('normalizes old and new SQL file read payloads', () => { + expect(normalizeSQLFileReadContent('select 1;')).toBe('select 1;'); + expect(normalizeSQLFileReadContent({ content: 'select 2;', filePath: '/tmp/a.sql' })).toBe('select 2;'); + expect(normalizeSQLFileReadContent({ isLargeFile: true, filePath: '/tmp/a.sql' })).toBe(''); + }); + + it('detects unsaved changes by comparing tab query with disk content', () => { + expect(hasSQLFileTabUnsavedChanges({ + type: 'query', + filePath: '/tmp/a.sql', + query: 'select 1;', + } as any, 'select 1;')).toBe(false); + + expect(hasSQLFileTabUnsavedChanges({ + type: 'query', + filePath: '/tmp/a.sql', + query: 'select 2;', + } as any, 'select 1;')).toBe(true); + }); +}); diff --git a/frontend/src/utils/sqlFileTabDirty.ts b/frontend/src/utils/sqlFileTabDirty.ts new file mode 100644 index 0000000..c8442ac --- /dev/null +++ b/frontend/src/utils/sqlFileTabDirty.ts @@ -0,0 +1,30 @@ +import type { TabData } from '../types'; + +const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); + +export const getSQLFileTabPath = (tab: Pick | null | undefined): string => { + if (!tab || tab.type !== 'query') return ''; + return toTrimmedString(tab.filePath); +}; + +export const isSQLFileQueryTab = (tab: Pick | null | undefined): boolean => + Boolean(getSQLFileTabPath(tab)); + +export const normalizeSQLFileReadContent = (data: unknown): string => { + if (data && typeof data === 'object') { + const payload = data as Record; + if ('content' in payload) { + return String(payload.content ?? ''); + } + return ''; + } + return String(data ?? ''); +}; + +export const hasSQLFileTabUnsavedChanges = ( + tab: Pick, + diskContent: string, +): boolean => { + if (!isSQLFileQueryTab(tab)) return false; + return String(tab.query ?? '') !== diskContent; +}; diff --git a/frontend/src/utils/sqlFileTabDrafts.test.ts b/frontend/src/utils/sqlFileTabDrafts.test.ts new file mode 100644 index 0000000..82e6829 --- /dev/null +++ b/frontend/src/utils/sqlFileTabDrafts.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + clearQueryTabDraft, + clearSQLFileTabDraft, + getQueryTabDraft, + getSQLFileTabDraft, + hasQueryTabDraft, + hasSQLFileTabDraft, + setQueryTabDraft, + setSQLFileTabDraft, +} from './sqlFileTabDrafts'; + +describe('sqlFileTabDrafts', () => { + it('stores query editor drafts outside the persisted tab state', () => { + clearQueryTabDraft('query-tab-1'); + + expect(hasQueryTabDraft('query-tab-1')).toBe(false); + expect(getQueryTabDraft('query-tab-1', 'fallback')).toBe('fallback'); + + setQueryTabDraft('query-tab-1', 'select * from large_table;'); + + expect(hasQueryTabDraft('query-tab-1')).toBe(true); + expect(getQueryTabDraft('query-tab-1', 'fallback')).toBe('select * from large_table;'); + + clearQueryTabDraft('query-tab-1'); + + expect(hasQueryTabDraft('query-tab-1')).toBe(false); + }); + + it('stores external SQL file editor drafts outside the persisted tab state', () => { + clearSQLFileTabDraft('tab-1'); + + expect(hasSQLFileTabDraft('tab-1')).toBe(false); + expect(getSQLFileTabDraft('tab-1', 'fallback')).toBe('fallback'); + + setSQLFileTabDraft('tab-1', 'select 1;'); + + expect(hasSQLFileTabDraft('tab-1')).toBe(true); + expect(getSQLFileTabDraft('tab-1', 'fallback')).toBe('select 1;'); + + clearSQLFileTabDraft('tab-1'); + + expect(hasSQLFileTabDraft('tab-1')).toBe(false); + }); +}); diff --git a/frontend/src/utils/sqlFileTabDrafts.ts b/frontend/src/utils/sqlFileTabDrafts.ts new file mode 100644 index 0000000..36397ae --- /dev/null +++ b/frontend/src/utils/sqlFileTabDrafts.ts @@ -0,0 +1,44 @@ +const drafts = new Map(); + +const toTabId = (value: unknown): string => String(value ?? '').trim(); + +export const setQueryTabDraft = (tabId: string, content: string): void => { + const id = toTabId(tabId); + if (!id) return; + drafts.set(id, String(content ?? '')); +}; + +export const getQueryTabDraft = (tabId: string, fallback = ''): string => { + const id = toTabId(tabId); + if (!id || !drafts.has(id)) { + return fallback; + } + return drafts.get(id) ?? fallback; +}; + +export const clearQueryTabDraft = (tabId: string): void => { + const id = toTabId(tabId); + if (!id) return; + drafts.delete(id); +}; + +export const hasQueryTabDraft = (tabId: string): boolean => { + const id = toTabId(tabId); + return Boolean(id && drafts.has(id)); +}; + +export const setSQLFileTabDraft = (tabId: string, content: string): void => { + setQueryTabDraft(tabId, content); +}; + +export const getSQLFileTabDraft = (tabId: string, fallback = ''): string => { + return getQueryTabDraft(tabId, fallback); +}; + +export const clearSQLFileTabDraft = (tabId: string): void => { + clearQueryTabDraft(tabId); +}; + +export const hasSQLFileTabDraft = (tabId: string): boolean => { + return hasQueryTabDraft(tabId); +}; diff --git a/frontend/src/utils/tabDisplay.test.ts b/frontend/src/utils/tabDisplay.test.ts index 77e80c6..6be9956 100644 --- a/frontend/src/utils/tabDisplay.test.ts +++ b/frontend/src/utils/tabDisplay.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from 'vitest'; import type { SavedConnection, TabData } from '../types'; -import { buildTabDisplayTitle, resolveConnectionHostSummary } from './tabDisplay'; +import { + applyTabDisplaySettingsPatch, + buildTabDisplayModel, + buildTabDisplayTitle, + resolveTabDisplayElementOrder, + resolveConnectionHostSummary, + sanitizeTabDisplaySettings, + switchTabDisplayLayout, + stripSchemaFromTabObjectLabel, +} from './tabDisplay'; const redisConnection: SavedConnection = { id: 'redis-1', @@ -65,4 +74,283 @@ describe('tabDisplay', () => { expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders'); }); + + it('hides schema prefixes from schema-qualified table tab labels', () => { + const connection: SavedConnection = { + id: 'kingbase-1', + name: 'Kingbase DEV', + config: { + type: 'kingbase', + host: '127.0.0.1', + port: 54321, + user: 'SYSTEM', + database: 'appdb', + }, + }; + const tableTab: TabData = { + id: 'kingbase-1-appdb-table-ldf_server.andon_events', + title: 'ldf_server.andon_events', + type: 'table', + connectionId: 'kingbase-1', + dbName: 'appdb', + tableName: 'ldf_server.andon_events', + }; + + expect(buildTabDisplayTitle(tableTab, connection)).toBe('[DEV] andon_events'); + }); + + it('hides schema prefixes from design and definition tab labels', () => { + const designTab: TabData = { + id: 'design-1', + title: '表结构 (public.orders)', + type: 'design', + connectionId: 'pg-1', + dbName: 'app', + tableName: 'public.orders', + readOnly: true, + }; + const viewTab: TabData = { + id: 'view-1', + title: '视图: reporting.active_users', + type: 'view-def', + connectionId: 'pg-1', + dbName: 'app', + viewName: 'reporting.active_users', + }; + const triggerTab: TabData = { + id: 'trigger-1', + title: '触发器: audit.users_bi', + type: 'trigger', + connectionId: 'pg-1', + dbName: 'app', + triggerName: 'audit.users_bi', + }; + const routineTab: TabData = { + id: 'routine-1', + title: '存储过程: reporting.refresh_stats', + type: 'routine-def', + connectionId: 'pg-1', + dbName: 'app', + routineName: 'reporting.refresh_stats', + routineType: 'PROCEDURE', + }; + + expect(buildTabDisplayTitle(designTab)).toBe('表结构 (orders)'); + expect(buildTabDisplayTitle(viewTab)).toBe('视图: active_users'); + expect(buildTabDisplayTitle(triggerTab)).toBe('触发器: users_bi'); + expect(buildTabDisplayTitle(routineTab)).toBe('存储过程: refresh_stats'); + }); + + it('keeps quoted dots inside object names when hiding schema prefixes', () => { + expect(stripSchemaFromTabObjectLabel('"sales.schema"."order.items"')).toBe('order.items'); + expect(stripSchemaFromTabObjectLabel('\\"ldf_server\\".\\"andon_events\\"')).toBe('andon_events'); + expect(stripSchemaFromTabObjectLabel('[dbo].[order.items]')).toBe('order.items'); + }); + + it('builds configurable single-line tab labels from ordered elements', () => { + const connection: SavedConnection = { + id: 'kingbase-1', + name: 'Kingbase DEV', + config: { + type: 'kingbase', + host: '192.168.10.8', + port: 54321, + user: 'SYSTEM', + database: 'appdb', + }, + }; + const tableTab: TabData = { + id: 'kingbase-1-appdb-table-ldf_server.andon_events', + title: 'ldf_server.andon_events', + type: 'table', + connectionId: 'kingbase-1', + dbName: 'appdb', + tableName: 'ldf_server.andon_events', + }; + + expect(buildTabDisplayTitle(tableTab, connection, { + layout: 'single', + primaryElements: ['object', 'schema', 'host'], + secondaryElements: [], + })).toBe('andon_events SCHEMA:ldf_server 192.168.10.8'); + }); + + it('builds the default configurable model with connection, type and compact object name', () => { + const connection: SavedConnection = { + id: 'kingbase-1', + name: 'Kingbase DEV', + config: { + type: 'kingbase', + host: '192.168.10.8', + port: 54321, + user: 'SYSTEM', + database: 'appdb', + }, + }; + const tableTab: TabData = { + id: 'kingbase-1-appdb-table-ldf_server.andon_events', + title: 'ldf_server.andon_events', + type: 'table', + connectionId: 'kingbase-1', + dbName: 'appdb', + tableName: 'ldf_server.andon_events', + }; + + expect(buildTabDisplayModel(tableTab, connection).fullTitle).toBe('[DEV] TABLE andon_events'); + }); + + it('keeps query tab labels compact when the title is raw SQL', () => { + const connection: SavedConnection = { + id: 'mysql-1', + name: '开发240', + config: { + type: 'mysql', + host: '192.168.1.240', + port: 3306, + user: 'root', + database: 'front_end_sys', + }, + }; + const queryTab: TabData = { + id: 'query-1', + title: 'select * from fs_org_auth_application where application_id is not null;', + type: 'query', + connectionId: 'mysql-1', + dbName: 'front_end_sys', + query: 'select * from fs_org_auth_application where application_id is not null;', + }; + + const model = buildTabDisplayModel(queryTab, connection); + + expect(model.primaryText).toBe('[开发240] SQL 新建查询'); + expect(model.fullTitle).not.toContain('fs_org_auth_application'); + expect(model.fullTitle).not.toContain('select *'); + }); + + it('uses SQL file names as compact query tab object labels', () => { + const queryTab: TabData = { + id: 'query-file-1', + title: 'select * from very_long_table_name;', + type: 'query', + connectionId: 'mysql-1', + filePath: '/Users/me/sql/monthly-report.sql', + query: 'select * from very_long_table_name;', + }; + + const model = buildTabDisplayModel(queryTab); + + expect(model.primaryText).toBe('SQL monthly-report.sql'); + }); + + it('builds configurable double-line tab display models', () => { + 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 model = buildTabDisplayModel(tableTab, connection, { + layout: 'double', + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database', 'schema', 'host'], + }); + + expect(model.layout).toBe('double'); + expect(model.primaryText).toBe('TABLE events'); + expect(model.secondaryText).toBe('[PROD]·analytics·SCHEMA:reporting·10.0.0.9'); + expect(model.fullTitle).toBe('TABLE events · [PROD]·analytics·SCHEMA:reporting·10.0.0.9'); + }); + + it('sanitizes tab display settings with fallback defaults', () => { + expect(sanitizeTabDisplaySettings({ + layout: 'invalid' as never, + primaryElements: ['schema', 'schema', 'bad' as never], + secondaryElements: ['object', 'schema', 'host'], + })).toEqual({ + layout: 'single', + primaryElements: ['schema'], + secondaryElements: ['object', 'host'], + }); + + expect(sanitizeTabDisplaySettings({ + layout: 'double', + primaryElements: ['bad' as never], + secondaryElements: [], + })).toEqual({ + layout: 'double', + primaryElements: ['connection', 'kind', 'object'], + secondaryElements: [], + }); + + expect(sanitizeTabDisplaySettings({ + layout: 'single', + primaryElements: ['object'], + secondaryElements: [], + single: { + primaryElements: ['object', 'object', 'host'], + secondaryElements: ['bad' as never], + }, + double: { + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'kind', 'schema'], + }, + })).toEqual({ + layout: 'single', + primaryElements: ['object'], + secondaryElements: [], + single: { + primaryElements: ['object', 'host'], + secondaryElements: [], + }, + double: { + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'schema'], + }, + }); + }); + + it('resolves visible tab display elements before hidden elements', () => { + expect(resolveTabDisplayElementOrder({ + layout: 'double', + primaryElements: ['object', 'kind'], + secondaryElements: ['host'], + })).toEqual(['object', 'kind', 'host', 'connection', 'database', 'schema']); + }); + + it('keeps separate single-line and double-line settings when switching layouts', () => { + const doubleConfigured = sanitizeTabDisplaySettings({ + layout: 'double', + primaryElements: ['kind', 'object'], + secondaryElements: ['connection', 'database'], + }); + + const singleLayout = switchTabDisplayLayout(doubleConfigured, 'single'); + const singleConfigured = applyTabDisplaySettingsPatch(singleLayout, { + primaryElements: ['object', 'host'], + secondaryElements: [], + }); + const restoredDouble = switchTabDisplayLayout(singleConfigured, 'double'); + const restoredSingle = switchTabDisplayLayout(restoredDouble, 'single'); + + expect(restoredDouble.layout).toBe('double'); + expect(restoredDouble.primaryElements).toEqual(['kind', 'object']); + expect(restoredDouble.secondaryElements).toEqual(['connection', 'database']); + expect(restoredSingle.layout).toBe('single'); + expect(restoredSingle.primaryElements).toEqual(['object', 'host']); + expect(restoredSingle.secondaryElements).toEqual([]); + }); }); diff --git a/frontend/src/utils/tabDisplay.ts b/frontend/src/utils/tabDisplay.ts index b834a15..8b41b83 100644 --- a/frontend/src/utils/tabDisplay.ts +++ b/frontend/src/utils/tabDisplay.ts @@ -1,5 +1,175 @@ import type { ConnectionConfig, SavedConnection, TabData } from '../types'; +export const TAB_DISPLAY_ELEMENT_KEYS = ['connection', 'kind', 'object', 'database', 'schema', 'host'] as const; + +export type TabDisplayElementKey = typeof TAB_DISPLAY_ELEMENT_KEYS[number]; +export type TabDisplayLayout = 'single' | 'double'; + +export interface TabDisplayLayoutSnapshot { + primaryElements: TabDisplayElementKey[]; + secondaryElements: TabDisplayElementKey[]; +} + +export interface TabDisplaySettings { + layout: TabDisplayLayout; + primaryElements: TabDisplayElementKey[]; + secondaryElements: TabDisplayElementKey[]; + single?: TabDisplayLayoutSnapshot; + double?: TabDisplayLayoutSnapshot; +} + +export const TAB_DISPLAY_SECONDARY_DEFAULT_KEYS: TabDisplayElementKey[] = ['connection', 'database', 'schema', 'host']; + +export const TAB_DISPLAY_ELEMENT_META: Record = { + connection: { label: '连接名', description: '连接简称或环境名,例如 DEV' }, + kind: { label: '对象类型', description: 'SQL / TABLE / VIEW 等类型标签' }, + object: { label: '对象名', description: '表名、查询名、资源名等核心名称' }, + database: { label: '数据库', description: '当前 DB / catalog 名称' }, + schema: { label: 'Schema', description: 'schema / owner 前缀' }, + host: { label: 'Host/IP', description: '连接目标地址摘要' }, +}; + +export const DEFAULT_TAB_DISPLAY_SETTINGS: TabDisplaySettings = { + layout: 'single', + primaryElements: ['connection', 'kind', 'object'], + secondaryElements: [], +}; + +export const getCurrentTabDisplaySnapshot = (settings: TabDisplaySettings): TabDisplayLayoutSnapshot => ({ + primaryElements: [...settings.primaryElements], + secondaryElements: [...settings.secondaryElements], +}); + +export const getDefaultTabDisplaySnapshot = (layout: TabDisplayLayout): TabDisplayLayoutSnapshot => { + if (layout === 'single') { + return { + primaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements], + secondaryElements: [], + }; + } + + return { + primaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements], + secondaryElements: TAB_DISPLAY_SECONDARY_DEFAULT_KEYS.filter((key) => !DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements.includes(key)), + }; +}; + +export const getSavedTabDisplaySnapshot = ( + settings: TabDisplaySettings, + layout: TabDisplayLayout, +): TabDisplayLayoutSnapshot => { + const saved = settings[layout]; + if (saved) { + return { + primaryElements: [...saved.primaryElements], + secondaryElements: [...saved.secondaryElements], + }; + } + if (settings.layout === layout) { + return getCurrentTabDisplaySnapshot(settings); + } + return getDefaultTabDisplaySnapshot(layout); +}; + +export const applyTabDisplaySettingsPatch = ( + currentSettings: TabDisplaySettings, + patch: Partial, +): TabDisplaySettings => { + const nextSettings = sanitizeTabDisplaySettings({ + ...currentSettings, + ...patch, + }); + const nextSnapshot = getCurrentTabDisplaySnapshot(nextSettings); + return sanitizeTabDisplaySettings({ + ...nextSettings, + [nextSettings.layout]: nextSnapshot, + }); +}; + +export const switchTabDisplayLayout = ( + currentSettings: TabDisplaySettings, + layout: TabDisplayLayout, +): TabDisplaySettings => { + if (layout === currentSettings.layout) { + return sanitizeTabDisplaySettings(currentSettings); + } + const currentSnapshot = getCurrentTabDisplaySnapshot(currentSettings); + const targetSnapshot = getSavedTabDisplaySnapshot(currentSettings, layout); + return sanitizeTabDisplaySettings({ + ...currentSettings, + [currentSettings.layout]: currentSnapshot, + layout, + primaryElements: targetSnapshot.primaryElements, + secondaryElements: targetSnapshot.secondaryElements, + [layout]: targetSnapshot, + }); +}; + +const isTabDisplayElementKey = (value: unknown): value is TabDisplayElementKey => ( + typeof value === 'string' && (TAB_DISPLAY_ELEMENT_KEYS as readonly string[]).includes(value) +); + +const sanitizeTabDisplayElementList = ( + value: unknown, + used: Set, +): TabDisplayElementKey[] => { + if (!Array.isArray(value)) return []; + const result: TabDisplayElementKey[] = []; + value.forEach((entry) => { + if (!isTabDisplayElementKey(entry) || used.has(entry)) return; + used.add(entry); + result.push(entry); + }); + return result; +}; + +const sanitizeTabDisplayLayoutSnapshot = (value: unknown): TabDisplayLayoutSnapshot | null => { + if (!value || typeof value !== 'object') { + return null; + } + const raw = value as Partial; + const used = new Set(); + const primaryElements = sanitizeTabDisplayElementList(raw.primaryElements, used); + const secondaryElements = sanitizeTabDisplayElementList(raw.secondaryElements, used); + return { + primaryElements: primaryElements.length > 0 ? primaryElements : [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements], + secondaryElements, + }; +}; + +export const sanitizeTabDisplaySettings = (value: unknown): TabDisplaySettings => { + if (!value || typeof value !== 'object') { + return { ...DEFAULT_TAB_DISPLAY_SETTINGS, primaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements], secondaryElements: [...DEFAULT_TAB_DISPLAY_SETTINGS.secondaryElements] }; + } + const raw = value as Partial; + const used = new Set(); + const primaryElements = sanitizeTabDisplayElementList(raw.primaryElements, used); + const secondaryElements = sanitizeTabDisplayElementList(raw.secondaryElements, used); + const result: TabDisplaySettings = { + layout: raw.layout === 'double' ? 'double' : 'single', + primaryElements: primaryElements.length > 0 ? primaryElements : [...DEFAULT_TAB_DISPLAY_SETTINGS.primaryElements], + secondaryElements, + }; + const single = sanitizeTabDisplayLayoutSnapshot(raw.single); + const double = sanitizeTabDisplayLayoutSnapshot(raw.double); + if (single) { + result.single = single; + } + if (double) { + result.double = double; + } + return result; +}; + +export const resolveTabDisplayElementOrder = (settings?: Partial | null): TabDisplayElementKey[] => { + const sanitized = sanitizeTabDisplaySettings(settings); + const visible = [...sanitized.primaryElements, ...sanitized.secondaryElements]; + return [ + ...visible, + ...TAB_DISPLAY_ELEMENT_KEYS.filter((key) => !visible.includes(key)), + ]; +}; + export const detectConnectionEnvLabel = (connectionName: string): string | null => { const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); if (tokens.includes('prod') || tokens.includes('production')) return 'PROD'; @@ -78,7 +248,321 @@ const buildRedisBaseTitle = (tab: TabData): string => { return dbLabel; }; -export const buildTabDisplayTitle = (tab: TabData, connection?: SavedConnection): string => { +const splitQualifiedIdentifier = (value: unknown): string[] => { + const raw = String(value || '').trim(); + if (!raw) return []; + + const parts: string[] = []; + let current = ''; + let quote: '"' | '`' | '[' | null = null; + + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]; + const next = raw[index + 1]; + + if (char === '\\' && next === '"') { + current += '"'; + index += 1; + continue; + } + + if (quote === '"') { + current += char; + if (char === '"' && next === '"') { + current += next; + index += 1; + } else if (char === '"') { + quote = null; + } + continue; + } + + if (quote === '`') { + current += char; + if (char === '`' && next === '`') { + current += next; + index += 1; + } else if (char === '`') { + quote = null; + } + continue; + } + + if (quote === '[') { + current += char; + if (char === ']') { + quote = null; + } + continue; + } + + if (char === '"' || char === '`' || char === '[') { + quote = char; + current += char; + continue; + } + + if (char === '.') { + parts.push(current.trim()); + current = ''; + continue; + } + + current += char; + } + + parts.push(current.trim()); + return parts.filter(Boolean); +}; + +const unwrapIdentifierLabel = (value: string): string => { + let text = String(value || '').trim().replace(/\\"/g, '"'); + if (!text) return ''; + + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === '`' && last === '`')) { + text = text.slice(1, -1); + } else if (first === '[' && last === ']') { + text = text.slice(1, -1); + } + + return text + .replace(/""/g, '"') + .replace(/``/g, '`') + .replace(/\]\]/g, ']') + .trim(); +}; + +export const stripSchemaFromTabObjectLabel = (value: unknown): string => { + const raw = String(value || '').trim(); + if (!raw) return ''; + + const parts = splitQualifiedIdentifier(raw); + const lastPart = parts[parts.length - 1] || raw; + return unwrapIdentifierLabel(lastPart) || raw; +}; + +const getSchemaFromTabObjectLabel = (value: unknown): string => { + const parts = splitQualifiedIdentifier(value); + if (parts.length <= 1) return ''; + return parts.slice(0, -1).map((part) => unwrapIdentifierLabel(part)).filter(Boolean).join('.'); +}; + +const replaceTitleObjectLabel = (title: string, objectName?: string): string => { + const rawTitle = String(title || '').trim(); + if (!rawTitle) return rawTitle; + + const rawObjectName = String(objectName || '').trim(); + const displayObjectName = stripSchemaFromTabObjectLabel(rawObjectName); + if (rawObjectName && displayObjectName && displayObjectName !== rawObjectName) { + const lastIndex = rawTitle.lastIndexOf(rawObjectName); + if (lastIndex >= 0) { + return `${rawTitle.slice(0, lastIndex)}${displayObjectName}${rawTitle.slice(lastIndex + rawObjectName.length)}`; + } + } + + const parenMatch = rawTitle.match(/^(.*\()([^()]*)\)(\s*)$/); + if (parenMatch) { + const objectLabel = stripSchemaFromTabObjectLabel(parenMatch[2]); + return `${parenMatch[1]}${objectLabel})${parenMatch[3]}`; + } + + const colonMatch = rawTitle.match(/^([^::]+[::]\s*)(.+)$/); + if (colonMatch) { + return `${colonMatch[1]}${stripSchemaFromTabObjectLabel(colonMatch[2])}`; + } + + return stripSchemaFromTabObjectLabel(rawTitle); +}; + +const stripSchemaFromTableOverviewTitle = (title: string): string => { + const rawTitle = String(title || '').trim(); + return rawTitle.replace(/\s+\([^()]+\)\s*$/, '').trim() || rawTitle; +}; + +const QUERY_TAB_FALLBACK_TITLE = '新建查询'; +const QUERY_TAB_TITLE_MAX_LENGTH = 28; + +const getFileNameFromPath = (value: string): string => ( + value.split(/[\\/]/).filter(Boolean).pop() || value +); + +const isLikelyRawSqlTitle = (value: string): boolean => { + const text = value.trim(); + if (!text) return false; + if (/[\r\n;]/.test(text)) return true; + return /^(select|with|insert|update|delete|merge|create|alter|drop|truncate|explain|show|desc|describe)\b/i.test(text); +}; + +const compactQueryTabTitle = (tab: TabData): string => { + const filePath = String(tab.filePath || '').trim(); + if (filePath) { + return getFileNameFromPath(filePath); + } + + const rawTitle = String(tab.title || '').trim(); + const title = rawTitle && !isLikelyRawSqlTitle(rawTitle) ? rawTitle : QUERY_TAB_FALLBACK_TITLE; + if (title.length <= QUERY_TAB_TITLE_MAX_LENGTH) { + return title; + } + return `${title.slice(0, QUERY_TAB_TITLE_MAX_LENGTH - 3)}...`; +}; + +const buildCompactObjectTabTitle = (tab: TabData): string => { + if (tab.type === 'query') { + return compactQueryTabTitle(tab); + } + if (tab.type === 'table') { + return stripSchemaFromTabObjectLabel(tab.tableName || tab.title) || tab.title; + } + if (tab.type === 'design') { + return replaceTitleObjectLabel(tab.title, tab.tableName); + } + if (tab.type === 'table-overview') { + return stripSchemaFromTableOverviewTitle(tab.title); + } + if (tab.type === 'view-def') { + return replaceTitleObjectLabel(tab.title, tab.viewName); + } + if (tab.type === 'trigger') { + return replaceTitleObjectLabel(tab.title, tab.triggerName); + } + if (tab.type === 'event-def') { + return replaceTitleObjectLabel(tab.title, tab.eventName); + } + if (tab.type === 'routine-def') { + return replaceTitleObjectLabel(tab.title, tab.routineName); + } + return tab.title; +}; + +export const getTabDisplayKindLabel = (tab: TabData): string => { + if (tab.type === 'query') return 'SQL'; + if (tab.type === 'table') return 'TABLE'; + if (tab.type === 'design') return 'DESIGN'; + if (tab.type === 'table-overview') return 'DB'; + if (tab.type.startsWith('redis')) return 'REDIS'; + if (tab.type.startsWith('jvm')) return 'JVM'; + if (tab.type === 'trigger') return 'TRG'; + if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? 'MV' : 'VIEW'; + if (tab.type === 'event-def') return 'EVT'; + if (tab.type === 'routine-def') return 'FUNC'; + return 'TAB'; +}; + +const getTabRawObjectLabel = (tab: TabData): string => { + if (tab.type === 'query') return compactQueryTabTitle(tab); + if (tab.tableName) return tab.tableName; + if (tab.viewName) return tab.viewName; + if (tab.eventName) return tab.eventName; + if (tab.routineName) return tab.routineName; + if (tab.triggerName) return tab.triggerName; + if (tab.resourcePath) return tab.resourcePath; + if (tab.filePath) return getFileNameFromPath(tab.filePath); + if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`; + return tab.title; +}; + +const getTabConnectionLabel = (connection?: SavedConnection): string => { + const connectionName = String(connection?.name || '').trim(); + return detectConnectionEnvLabel(connectionName) || connectionName; +}; + +const getTabDisplayElementValue = ( + key: TabDisplayElementKey, + tab: TabData, + connection?: SavedConnection, +): string => { + const rawObjectLabel = getTabRawObjectLabel(tab); + switch (key) { + case 'connection': + return getTabConnectionLabel(connection); + case 'kind': + return getTabDisplayKindLabel(tab); + case 'object': + return buildCompactObjectTabTitle({ + ...tab, + title: tab.type === 'table' || tab.type === 'query' ? rawObjectLabel : tab.title, + }); + case 'database': + return String(tab.dbName || '').trim(); + case 'schema': + return getSchemaFromTabObjectLabel(rawObjectLabel); + case 'host': + return resolveConnectionHostSummary(connection?.config); + default: + return ''; + } +}; + +const formatTabDisplayPartValue = (key: TabDisplayElementKey, value: string): string => { + if (!value) return ''; + if (key === 'connection') return `[${value}]`; + if (key === 'schema') return `SCHEMA:${value}`; + return value; +}; + +export interface TabDisplayPart { + key: TabDisplayElementKey; + value: string; + text: string; +} + +export interface TabDisplayModel { + layout: TabDisplayLayout; + primaryParts: TabDisplayPart[]; + secondaryParts: TabDisplayPart[]; + primaryText: string; + secondaryText: string; + fullTitle: string; +} + +const buildTabDisplayParts = ( + keys: TabDisplayElementKey[], + tab: TabData, + connection?: SavedConnection, +): TabDisplayPart[] => keys + .map((key) => { + const value = getTabDisplayElementValue(key, tab, connection); + return { + key, + value, + text: formatTabDisplayPartValue(key, value), + }; + }) + .filter((part) => part.text); + +export const buildTabDisplayModel = ( + tab: TabData, + connection?: SavedConnection, + settings?: Partial | null, +): TabDisplayModel => { + const sanitized = sanitizeTabDisplaySettings(settings); + const primaryParts = buildTabDisplayParts(sanitized.primaryElements, tab, connection); + const secondaryParts = buildTabDisplayParts(sanitized.secondaryElements, tab, connection); + const primaryText = primaryParts.map((part) => part.text).join(' ').trim() || buildCompactObjectTabTitle(tab); + const secondaryText = secondaryParts.map((part) => part.text).join('·').trim(); + const fullTitle = [primaryText, secondaryText].filter(Boolean).join(' · '); + return { + layout: sanitized.layout, + primaryParts, + secondaryParts, + primaryText, + secondaryText, + fullTitle, + }; +}; + +export const buildTabDisplayTitle = ( + tab: TabData, + connection?: SavedConnection, + settings?: Partial | null, +): string => { + if (settings) { + return buildTabDisplayModel(tab, connection, settings).fullTitle; + } + const connectionName = String(connection?.name || '').trim(); if (isRedisTab(tab)) { @@ -87,13 +571,14 @@ export const buildTabDisplayTitle = (tab: TabData, connection?: SavedConnection) return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab); } + const baseTitle = buildCompactObjectTabTitle(tab); if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') { - return tab.title; + return baseTitle; } if (!connectionName) { - return tab.title; + return baseTitle; } const prefix = detectConnectionEnvLabel(connectionName) || connectionName; - return `[${prefix}] ${tab.title}`; + return `[${prefix}] ${baseTitle}`; };