feat(tabs): 支持标签展示配置并提示保存 SQL 文件

- 新增标签展示元素配置,支持单行、双行布局和元素排序

- 在设置面板提供标签展示入口并持久化用户配置

- 标签右键菜单增加标签设置入口并优化悬浮信息展示

- 关闭外部 SQL 文件标签前检测未保存草稿并支持保存后关闭
This commit is contained in:
Syngnat
2026-06-02 11:16:25 +08:00
parent e6dd986115
commit c405eb08b5
11 changed files with 1664 additions and 60 deletions

View File

@@ -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<TabDisplayElementKey>([
...tabDisplaySettings.primaryElements,
...tabDisplaySettings.secondaryElements,
]),
[tabDisplaySettings],
);
const setTabDisplaySettings = useCallback((settings: Partial<TabDisplaySettings>) => {
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<SecurityUpdateRepairSource | null>(null);
const [focusedTabDisplayElementKey, setFocusedTabDisplayElementKey] = useState<TabDisplayElementKey | null>(null);
const [focusedAIProviderId, setFocusedAIProviderId] = useState<string | undefined>(undefined);
const [connectionPackageDialog, setConnectionPackageDialog] = useState<ConnectionPackageDialogState>(() => createClosedConnectionPackageDialogState());
const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState<string | null>(null);
@@ -2082,6 +2178,8 @@ function App() {
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const tabDisplaySettingsPanelRef = useRef<HTMLDivElement | null>(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<Record<ShortcutAction, ConflictInfo[]>> = {};
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() {
</div>
</div>
</div>
<div ref={tabDisplaySettingsPanelRef} style={utilityPanelStyle}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>Tab </div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>
Schema Host/IP
</div>
</div>
<Segmented
size="small"
options={[
{ label: '单行', value: 'single' },
{ label: '双行', value: 'double' },
]}
value={tabDisplaySettings.layout}
onChange={(value) => setTabDisplayLayout(value as TabDisplayLayout)}
/>
</div>
<div style={{ display: 'grid', gap: 8 }}>
{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 (
<div
key={key}
role="button"
tabIndex={0}
onClick={() => 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',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
width: 22,
height: 22,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontFamily: resolvedMonoFontFamily,
fontSize: 11,
fontWeight: 800,
background: isFocused
? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.14)')
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(16,24,40,0.05)'),
color: isFocused
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(16,24,40,0.5)'),
}}>
{checked && indexInRow >= 0 ? indexInRow + 1 : '-'}
</span>
<Switch
size="small"
checked={checked}
onClick={(_, event) => event.stopPropagation()}
onChange={(nextChecked) => updateTabDisplayElementVisibility(key, nextChecked)}
/>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ fontWeight: 600 }}>{meta.label}</span>
{isFocused ? (
<span style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
borderRadius: 999,
background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)',
color: darkMode ? '#ffd666' : '#1677ff',
}}>
</span>
) : null}
{checked && tabDisplaySettings.layout === 'double' ? (
<span style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
borderRadius: 999,
background: row === 'secondary'
? (darkMode ? 'rgba(56,189,248,0.14)' : 'rgba(2,132,199,0.08)')
: (darkMode ? 'rgba(34,197,94,0.14)' : 'rgba(22,163,74,0.08)'),
color: row === 'secondary'
? (darkMode ? '#7dd3fc' : '#0369a1')
: (darkMode ? '#86efac' : '#15803d'),
}}>
{row === 'secondary' ? '副行' : '主行'}
</span>
) : null}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 2 }}>{meta.description}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{tabDisplaySettings.layout === 'double' && checked ? (
<Segmented
size="small"
options={[
{ label: '主行', value: 'primary' },
{ label: '副行', value: 'secondary' },
]}
value={row}
onChange={(value) => setTabDisplayElementRow(key, value as 'primary' | 'secondary')}
onClick={(event) => event.stopPropagation()}
/>
) : null}
<Button
size="small"
disabled={!canMoveUp}
onClick={(event) => {
event.stopPropagation();
moveTabDisplayElement(key, -1);
}}
>
</Button>
<Button
size="small"
disabled={!canMoveDown}
onClick={(event) => {
event.stopPropagation();
moveTabDisplayElement(key, 1);
}}
>
</Button>
</div>
</div>
);
})}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{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}`
: ''}
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>

View File

@@ -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: <SettingOutlined />');
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())');
});
});

View File

@@ -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<HTMLElement>) => {
event.stopPropagation();
};
@@ -81,6 +97,13 @@ export const stopTabHoverDragPropagation = (event: React.SyntheticEvent<HTMLElem
export const resolveTabHoverOpen = (isHoverInfoOpen: boolean, isTabMenuOpen: boolean) =>
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<TabHoverInfoProps> = ({
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<TabHoverInfoProps> = ({
>
<div className="gn-v2-tab-hover-head">
<span>{getTabKindLabel(tab)}</span>
<strong>{displayTitle}</strong>
<strong>{hoverTitle}</strong>
</div>
<div className="gn-v2-tab-hover-rows">
{rows.map(([label, value]) => (
@@ -155,6 +204,7 @@ export const TabHoverInfo: React.FC<TabHoverInfoProps> = ({
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 (
<span className="gn-v2-tab-kind" key={part.key}>
{part.text}
</span>
);
}
return (
<span className={`gn-v2-tab-label-part gn-v2-tab-label-part-${part.key}`} key={part.key}>
{part.text}
</span>
);
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
tab,
displayModel,
displayTitle,
menuItems,
connectionLabel,
@@ -190,17 +256,30 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
setIsHoverInfoOpen(open && !isTabMenuOpen);
};
const tabDisplayPartCount = displayModel.primaryParts.length + displayModel.secondaryParts.length;
const showSecondaryLine = isV2Ui && displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
const labelNode = (
<span
className={`tab-dnd-label${isV2Ui ? ' gn-v2-tab-label' : ''}`}
className={`tab-dnd-label${isV2Ui ? ' gn-v2-tab-label' : ''}${showSecondaryLine ? ' gn-v2-tab-label-double' : ''}${tabDisplayPartCount >= 4 ? ' gn-v2-tab-label-rich' : ''}`}
onContextMenu={handleTabLabelContextMenu}
title={isV2Ui ? undefined : displayTitle}
>
{isV2Ui ? <span className="gn-v2-tab-kind">{getTabKindLabel(tab)}</span> : null}
<span className="tab-title-text">{displayTitle}</span>
{isV2Ui && shouldShowV2ConnectionLabel(displayTitle, connectionLabel) ? (
<span className="gn-v2-tab-conn">{connectionLabel}</span>
) : null}
{isV2Ui ? (
<span className="gn-v2-tab-label-content">
<span className="gn-v2-tab-label-main tab-title-text">
{displayModel.primaryParts.length > 0
? displayModel.primaryParts.map(renderV2TabDisplayPart)
: displayModel.primaryText}
</span>
{showSecondaryLine ? (
<span className="gn-v2-tab-label-secondary" title={displayModel.secondaryText}>
{displayModel.secondaryText}
</span>
) : null}
</span>
) : (
<span className="tab-title-text">{displayTitle}</span>
)}
{isV2Ui && onClose ? (
<button
type="button"
@@ -223,6 +302,7 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
title={(
<TabHoverInfo
tab={tab}
displayModel={displayModel}
displayTitle={displayTitle}
connectionLabel={connectionLabel}
hostSummary={hostSummary}
@@ -240,7 +320,12 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
) : labelNode;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} onOpenChange={handleTabMenuOpenChange}>
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
onOpenChange={handleTabMenuOpenChange}
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
>
{wrappedLabel}
</Dropdown>
);
@@ -343,14 +428,118 @@ const TabManager: React.FC = React.memo(() => {
);
const isV2Ui = appearance.uiVersion === 'v2';
const hasTabs = tabs.length > 0;
const pendingCloseTabIdsRef = useRef<Set<string>>(new Set());
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
};
const requestCloseSQLFileTabs = useCallback(async (
targetTabs: TabData[],
closeConfirmedTabs: () => void,
) => {
const candidateTabs = targetTabs.filter(isSQLFileQueryTab);
if (candidateTabs.length === 0) {
closeConfirmedTabs();
return;
}
const closeConfirmedTabsAndClearDrafts = () => {
closeConfirmedTabs();
candidateTabs.forEach((tab) => clearSQLFileTabDraft(tab.id));
};
const dirtyTabs: Array<{ tab: TabData; draft: string }> = [];
for (const tab of candidateTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
try {
const res = await ReadSQLFile(filePath);
if (!res.success) {
message.error(`读取 SQL 文件失败,已取消关闭:${res.message || filePath}`);
return;
}
const draft = getSQLFileTabDraft(tab.id, String(tab.query ?? ''));
if (hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))) {
dirtyTabs.push({ tab, draft });
}
} catch (error) {
message.error('读取 SQL 文件失败,已取消关闭:' + (error instanceof Error ? error.message : String(error)));
return;
}
}
if (dirtyTabs.length === 0) {
closeConfirmedTabsAndClearDrafts();
return;
}
const firstDirtyTab = dirtyTabs[0].tab;
const dirtyFilePath = getSQLFileTabPath(firstDirtyTab);
const dirtyLabel = dirtyTabs.length === 1
? `${firstDirtyTab.title || dirtyFilePath}`
: `${dirtyTabs.length} 个 SQL 文件`;
let destroyConfirm: (() => void) | null = null;
const confirmRef = Modal.confirm({
title: '保存 SQL 文件修改?',
content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`,
okText: '保存并关闭',
cancelText: '取消',
closable: true,
maskClosable: true,
okButtonProps: { type: 'primary' },
footer: (_, { OkBtn, CancelBtn }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
closeConfirmedTabsAndClearDrafts();
}}
>
</Button>
<CancelBtn />
<OkBtn />
</>
),
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) => (
<DefaultTabBar {...tabBarProps}>
@@ -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: <SettingOutlined />,
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: (
<SortableTabLabel
tab={tab}
displayModel={displayModel}
displayTitle={displayTitle}
menuItems={menuItems}
connectionLabel={connection?.name}
hostSummary={hostSummary}
isV2Ui={isV2Ui}
onClose={() => closeTab(tab.id)}
onClose={() => closeTabsWithSQLFilePrompt([tab.id], () => closeTab(tab.id))}
/>
),
key: tab.id,
closable: !isV2Ui,
children: <TabContent tab={tab} isActive={tabIsActive} />,
};
}), [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<HTMLButtonElement>('[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 {
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<Tabs
className={`main-tabs${isV2Ui ? ' gn-v2-main-tabs' : ''}`}
className={`main-tabs${isV2Ui ? ' gn-v2-main-tabs' : ''}${hasDoubleLineTabLabel ? ' gn-v2-main-tabs-double' : ''}`}
type="editable-card"
destroyOnHidden={false}
onChange={(newActiveKey) => {

View File

@@ -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,
},
]);

View File

@@ -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<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
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<AppState>()(
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<AppState>()(
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 {

View File

@@ -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);
});
});

View File

@@ -0,0 +1,30 @@
import type { TabData } from '../types';
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
export const getSQLFileTabPath = (tab: Pick<TabData, 'type' | 'filePath'> | null | undefined): string => {
if (!tab || tab.type !== 'query') return '';
return toTrimmedString(tab.filePath);
};
export const isSQLFileQueryTab = (tab: Pick<TabData, 'type' | 'filePath'> | null | undefined): boolean =>
Boolean(getSQLFileTabPath(tab));
export const normalizeSQLFileReadContent = (data: unknown): string => {
if (data && typeof data === 'object') {
const payload = data as Record<string, unknown>;
if ('content' in payload) {
return String(payload.content ?? '');
}
return '';
}
return String(data ?? '');
};
export const hasSQLFileTabUnsavedChanges = (
tab: Pick<TabData, 'type' | 'filePath' | 'query'>,
diskContent: string,
): boolean => {
if (!isSQLFileQueryTab(tab)) return false;
return String(tab.query ?? '') !== diskContent;
};

View File

@@ -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);
});
});

View File

@@ -0,0 +1,44 @@
const drafts = new Map<string, string>();
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);
};

View File

@@ -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([]);
});
});

View File

@@ -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<TabDisplayElementKey, { label: string; description: string }> = {
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>,
): 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>,
): 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<TabDisplayLayoutSnapshot>;
const used = new Set<TabDisplayElementKey>();
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<TabDisplaySettings>;
const used = new Set<TabDisplayElementKey>();
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<TabDisplaySettings> | 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<TabDisplaySettings> | 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<TabDisplaySettings> | 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}`;
};