mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-12 09:29:43 +08:00
✨ feat(tabs): 支持标签展示配置并提示保存 SQL 文件
- 新增标签展示元素配置,支持单行、双行布局和元素排序 - 在设置面板提供标签展示入口并持久化用户配置 - 标签右键菜单增加标签设置入口并优化悬浮信息展示 - 关闭外部 SQL 文件标签前检测未保存草稿并支持保存后关闭
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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())');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
37
frontend/src/utils/sqlFileTabDirty.test.ts
Normal file
37
frontend/src/utils/sqlFileTabDirty.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
frontend/src/utils/sqlFileTabDirty.ts
Normal file
30
frontend/src/utils/sqlFileTabDirty.ts
Normal 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;
|
||||
};
|
||||
46
frontend/src/utils/sqlFileTabDrafts.test.ts
Normal file
46
frontend/src/utils/sqlFileTabDrafts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
frontend/src/utils/sqlFileTabDrafts.ts
Normal file
44
frontend/src/utils/sqlFileTabDrafts.ts
Normal 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);
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user