mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-30 02:08:53 +08:00
✨ feat(sidebar): 支持表元数据显示配置
- 新增侧栏表元数据字段配置并兼容旧版表备注开关 - 设置中心新增侧栏表元数据入口并移除树上重复显示开关 - 批量补齐表大小、创建时间、修改时间等元数据加载与渲染 - 统一表元数据格式化逻辑并修复 V2 左树横向滚动裁切 - 补充设置中心、store、侧栏元数据相关回归测试与多语言文案
This commit is contained in:
38
frontend/src/App.settings-center.test.ts
Normal file
38
frontend/src/App.settings-center.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('settings center layout', () => {
|
||||
it('uses the same split navigation shell as the tool center', () => {
|
||||
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';");
|
||||
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
|
||||
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
|
||||
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
|
||||
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
|
||||
expect(appSource).toContain("title: t('app.settings.group.preferences.title')");
|
||||
expect(appSource).toContain("title: t('app.settings.group.services.title')");
|
||||
expect(appSource).toContain("title: t('app.settings.group.about.title')");
|
||||
});
|
||||
|
||||
it('moves sidebar table metadata configuration into the settings center', () => {
|
||||
expect(appSource).toContain("key: 'sidebar-metadata'");
|
||||
expect(appSource).toContain("title: t('app.settings.sidebar_metadata.title')");
|
||||
expect(appSource).toContain("description: t('app.settings.sidebar_metadata.description')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-metadata')");
|
||||
expect(appSource).toContain("setSidebarTableMetadataFieldSelected(");
|
||||
expect(appSource).toContain("sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS");
|
||||
expect(appSource).toContain("t('sidebar.v2_table_group_menu.display_table_rows')");
|
||||
expect(appSource).not.toContain("setIsLanguageModalOpen(true)");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import Modal from './components/common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetDarkTheme, WindowSetLightTheme, WindowSetPosition, WindowSetSize, WindowSetSystemDefaultTheme, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
@@ -72,6 +72,12 @@ import {
|
||||
stripLegacyPersistedConnectionById,
|
||||
} from './utils/legacyConnectionStorage';
|
||||
import { DEFAULT_QUERY_TEMPLATE } from './components/queryEditor/QueryEditorHelpers';
|
||||
import {
|
||||
DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS,
|
||||
resolveSidebarTableMetadataFields,
|
||||
setSidebarTableMetadataFieldSelected,
|
||||
type SidebarTableMetadataField,
|
||||
} from './utils/sidebarTableMetadata';
|
||||
import {
|
||||
getSecurityUpdateStatusMeta,
|
||||
resolveSecurityUpdateEntryVisibility,
|
||||
@@ -206,6 +212,13 @@ type ToolCenterPaneState = {
|
||||
group: ToolCenterGroupKey;
|
||||
};
|
||||
|
||||
type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';
|
||||
type SettingsCenterPaneKey = 'language' | 'sidebar-metadata' | 'proxy';
|
||||
type SettingsCenterPaneState = {
|
||||
key: SettingsCenterPaneKey;
|
||||
group: SettingsCenterGroupKey;
|
||||
};
|
||||
|
||||
type ConnectionPackageDialogState = {
|
||||
open: boolean;
|
||||
mode: ConnectionPackageDialogMode;
|
||||
@@ -253,6 +266,8 @@ function App() {
|
||||
const replaceConnections = useStore(state => state.replaceConnections);
|
||||
const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy);
|
||||
const replaceSavedQueries = useStore(state => state.replaceSavedQueries);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const updateShortcut = useStore(state => state.updateShortcut);
|
||||
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
|
||||
@@ -281,6 +296,13 @@ function App() {
|
||||
const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale);
|
||||
const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
|
||||
const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;
|
||||
const sidebarTableMetadataFields = useMemo(
|
||||
() => resolveSidebarTableMetadataFields(
|
||||
queryOptions?.sidebarTableMetadataFields,
|
||||
queryOptions?.showSidebarTableComment === true,
|
||||
),
|
||||
[queryOptions?.showSidebarTableComment, queryOptions?.sidebarTableMetadataFields],
|
||||
);
|
||||
const tabDisplaySettings = useMemo(
|
||||
() => sanitizeTabDisplaySettings(appearance.tabDisplay),
|
||||
[appearance.tabDisplay],
|
||||
@@ -1303,7 +1325,7 @@ function App() {
|
||||
toolCenterContentPanelStyle, toolCenterDetailBodyStyle, toolCenterDetailPanelStyle,
|
||||
toolCenterModalContentStyle, toolCenterModalSplitStyle, toolCenterModalWorkspaceStyle,
|
||||
toolCenterNavPanelStyle, toolCenterNavScrollStyle, toolCenterRowDescriptionStyle, toolCenterRowStyle,
|
||||
toolCenterScrollableListStyle, utilityActionCardStyle, utilityActionHintStyle, utilityButtonStyle,
|
||||
toolCenterScrollableListStyle, utilityButtonStyle,
|
||||
utilityModalShellStyle, utilityMutedTextStyle, utilityPanelStyle,
|
||||
} = useAppUtilityStyles({
|
||||
blurFilter,
|
||||
@@ -2128,7 +2150,8 @@ function App() {
|
||||
const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);
|
||||
const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
|
||||
const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');
|
||||
const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
|
||||
const [isLinuxCJKFontBannerDismissed, setIsLinuxCJKFontBannerDismissed] = useState(false);
|
||||
@@ -2256,7 +2279,14 @@ function App() {
|
||||
setActiveToolCenterGroupKey(group);
|
||||
setIsToolsModalOpen(true);
|
||||
}, []);
|
||||
const handleOpenSettingsModal = useCallback(() => {
|
||||
const handleOpenSettingsModal = useCallback((group: SettingsCenterGroupKey = 'preferences') => {
|
||||
setActiveSettingsCenterGroupKey(group);
|
||||
setActiveSettingsCenterPane(null);
|
||||
setIsSettingsModalOpen(true);
|
||||
}, []);
|
||||
const handleOpenSettingsCenterPane = useCallback((group: SettingsCenterGroupKey, key: SettingsCenterPaneKey) => {
|
||||
setActiveSettingsCenterGroupKey(group);
|
||||
setActiveSettingsCenterPane({ key, group });
|
||||
setIsSettingsModalOpen(true);
|
||||
}, []);
|
||||
const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {
|
||||
@@ -3089,6 +3119,250 @@ function App() {
|
||||
!fontFamiliesLoadError &&
|
||||
!isLinuxCJKFontBannerDismissed,
|
||||
);
|
||||
const sidebarMetadataFieldItems: Array<{ field: SidebarTableMetadataField; label: string }> = [
|
||||
{ field: 'comment', label: t('sidebar.v2_table_group_menu.show_table_comments') },
|
||||
{ field: 'rows', label: t('sidebar.v2_table_group_menu.display_table_rows') },
|
||||
{ field: 'size', label: t('sidebar.v2_table_group_menu.display_table_size') },
|
||||
{ field: 'createdAt', label: t('sidebar.v2_table_group_menu.display_create_time') },
|
||||
{ field: 'updatedAt', label: t('sidebar.v2_table_group_menu.display_update_time') },
|
||||
];
|
||||
const toggleSidebarMetadataFieldFromSettings = useCallback((field: SidebarTableMetadataField, selected: boolean) => {
|
||||
setQueryOptions({
|
||||
sidebarTableMetadataFields: setSidebarTableMetadataFieldSelected(
|
||||
sidebarTableMetadataFields,
|
||||
field,
|
||||
selected,
|
||||
),
|
||||
});
|
||||
}, [setQueryOptions, sidebarTableMetadataFields]);
|
||||
const renderProxySettingsContent = useCallback(() => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.proxy.section_title')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>{t('app.proxy.enable')}</span>
|
||||
<Switch checked={globalProxy.enabled} onChange={(checked) => setGlobalProxy({ enabled: checked })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, opacity: globalProxy.enabled ? 1 : 0.7 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.type')}</div>
|
||||
<Select
|
||||
value={globalProxy.type}
|
||||
disabled={!globalProxy.enabled}
|
||||
options={[
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
]}
|
||||
onChange={(value) => setGlobalProxy({ type: value as 'socks5' | 'http' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.port')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
value={globalProxy.port}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(value) => setGlobalProxy({
|
||||
port: typeof value === 'number' ? value : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.host')}</div>
|
||||
<Input
|
||||
placeholder={t('app.proxy.host_placeholder')}
|
||||
value={globalProxy.host}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.username_optional')}</div>
|
||||
<Input
|
||||
placeholder="proxy-user"
|
||||
value={globalProxy.user}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ user: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.password_optional')}</div>
|
||||
<Input.Password
|
||||
placeholder="proxy-password"
|
||||
value={globalProxy.password}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 6 }}>
|
||||
{t('app.proxy.scope_hint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), [darkMode, globalProxy.enabled, globalProxy.host, globalProxy.password, globalProxy.port, globalProxy.type, globalProxy.user, setGlobalProxy, t, utilityPanelStyle]);
|
||||
const renderSidebarMetadataSettingsPane = useCallback(() => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('app.settings.sidebar_metadata.title')}</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginBottom: 14 }}>
|
||||
{t('app.settings.sidebar_metadata.description')}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
{sidebarMetadataFieldItems.map((item) => {
|
||||
const checked = sidebarTableMetadataFields.includes(item.field);
|
||||
return (
|
||||
<div
|
||||
key={item.field}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingBottom: 12,
|
||||
borderBottom: `1px solid ${overlayTheme.divider}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{item.label}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(selected) => toggleSidebarMetadataFieldFromSettings(item.field, selected)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setQueryOptions({
|
||||
sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('app.theme.action.restore_defaults')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
), [
|
||||
overlayTheme.divider,
|
||||
overlayTheme.titleText,
|
||||
setQueryOptions,
|
||||
sidebarMetadataFieldItems,
|
||||
sidebarTableMetadataFields,
|
||||
t,
|
||||
toggleSidebarMetadataFieldFromSettings,
|
||||
utilityMutedTextStyle,
|
||||
utilityPanelStyle,
|
||||
]);
|
||||
const settingsCenterGroups = [
|
||||
{
|
||||
key: 'preferences' as const,
|
||||
icon: <SettingOutlined />,
|
||||
title: t('app.settings.group.preferences.title'),
|
||||
description: t('app.settings.group.preferences.description'),
|
||||
items: [
|
||||
{
|
||||
key: 'language',
|
||||
icon: <GlobalOutlined />,
|
||||
title: t('settings.language.title'),
|
||||
description: t('settings.language.description'),
|
||||
onClick: () => handleOpenSettingsCenterPane('preferences', 'language'),
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
icon: <SkinOutlined />,
|
||||
title: t('app.settings.entry.theme.title'),
|
||||
description: t('app.settings.entry.theme.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setThemeModalSection('theme');
|
||||
setIsThemeModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sidebar-metadata',
|
||||
icon: <TableOutlined />,
|
||||
title: t('app.settings.sidebar_metadata.title'),
|
||||
description: t('app.settings.sidebar_metadata.description'),
|
||||
onClick: () => handleOpenSettingsCenterPane('preferences', 'sidebar-metadata'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'services' as const,
|
||||
icon: <GlobalOutlined />,
|
||||
title: t('app.settings.group.services.title'),
|
||||
description: t('app.settings.group.services.description'),
|
||||
items: [
|
||||
{
|
||||
key: 'proxy',
|
||||
icon: <GlobalOutlined />,
|
||||
title: t('app.settings.entry.proxy.title'),
|
||||
description: t('app.settings.entry.proxy.description'),
|
||||
onClick: () => handleOpenSettingsCenterPane('services', 'proxy'),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
icon: <RobotOutlined />,
|
||||
title: t('app.settings.entry.ai.title'),
|
||||
description: t('app.settings.entry.ai.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
handleOpenAISettings();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'about' as const,
|
||||
icon: <InfoCircleOutlined />,
|
||||
title: t('app.settings.group.about.title'),
|
||||
description: t('app.settings.group.about.description'),
|
||||
items: [
|
||||
{
|
||||
key: 'about-go-navi',
|
||||
icon: <InfoCircleOutlined />,
|
||||
title: t('app.settings.entry.about.title'),
|
||||
description: t('app.settings.entry.about.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setIsAboutOpen(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const activeSettingsCenterGroup = settingsCenterGroups.find((group) => group.key === activeSettingsCenterGroupKey) ?? settingsCenterGroups[0];
|
||||
const activeSettingsCenterPaneItem = activeSettingsCenterPane
|
||||
? settingsCenterGroups
|
||||
.find((group) => group.key === activeSettingsCenterPane.group)
|
||||
?.items.find((item) => item.key === activeSettingsCenterPane.key)
|
||||
: null;
|
||||
const renderSettingsCenterPane = () => {
|
||||
if (!activeSettingsCenterPane) {
|
||||
return null;
|
||||
}
|
||||
if (activeSettingsCenterPane.key === 'language') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<LanguageSettingsPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activeSettingsCenterPane.key === 'sidebar-metadata') {
|
||||
return renderSidebarMetadataSettingsPane();
|
||||
}
|
||||
if (activeSettingsCenterPane.key === 'proxy') {
|
||||
return renderProxySettingsContent();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
@@ -4123,91 +4397,174 @@ function App() {
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<SettingOutlined />, t('app.settings.title'), t('app.settings.description'))}
|
||||
open={isSettingsModalOpen}
|
||||
onCancel={() => setIsSettingsModalOpen(false)}
|
||||
onCancel={() => {
|
||||
setActiveSettingsCenterPane(null);
|
||||
setIsSettingsModalOpen(false);
|
||||
}}
|
||||
footer={null}
|
||||
width={560}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
centered
|
||||
width={1080}
|
||||
styles={{
|
||||
content: toolCenterModalContentStyle,
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||||
body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 },
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12, padding: '12px 0' }}>
|
||||
{[
|
||||
{
|
||||
key: 'language',
|
||||
icon: <GlobalOutlined />,
|
||||
title: t('settings.language.title'),
|
||||
description: t('settings.language.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setIsLanguageModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
icon: <SkinOutlined />,
|
||||
title: t('app.settings.entry.theme.title'),
|
||||
description: t('app.settings.entry.theme.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setThemeModalSection('theme');
|
||||
setIsThemeModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'proxy',
|
||||
icon: <GlobalOutlined />,
|
||||
title: t('app.settings.entry.proxy.title'),
|
||||
description: t('app.settings.entry.proxy.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setSecurityUpdateRepairSource(null);
|
||||
setIsProxyModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
icon: <RobotOutlined />,
|
||||
title: t('app.settings.entry.ai.title'),
|
||||
description: t('app.settings.entry.ai.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
handleOpenAISettings();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
icon: <InfoCircleOutlined />,
|
||||
title: t('app.settings.entry.about.title'),
|
||||
description: t('app.settings.entry.about.description'),
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setIsAboutOpen(true);
|
||||
},
|
||||
},
|
||||
].map((item) => (
|
||||
<Button key={item.key} type="text" style={utilityActionCardStyle} onClick={item.onClick}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span style={{ display: 'grid', gap: 4, textAlign: 'left', minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: overlayTheme.titleText }}>{item.title}</span>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.mutedText, whiteSpace: 'normal' }}>{item.description}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<div style={toolCenterModalWorkspaceStyle}>
|
||||
<div style={toolCenterModalSplitStyle}>
|
||||
<div style={toolCenterNavPanelStyle}>
|
||||
<div style={toolCenterNavScrollStyle} role="tablist" aria-orientation="vertical">
|
||||
{settingsCenterGroups.map((group) => {
|
||||
const active = group.key === activeSettingsCenterGroup.key;
|
||||
return (
|
||||
<button
|
||||
key={group.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
title={`${group.title} - ${group.description}`}
|
||||
onClick={() => {
|
||||
setActiveSettingsCenterGroupKey(group.key);
|
||||
setActiveSettingsCenterPane(null);
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
padding: '11px 10px 11px 14px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: active ? overlayTheme.selectedBg : 'transparent',
|
||||
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
width: 3,
|
||||
borderRadius: 999,
|
||||
background: active ? overlayTheme.selectedText : 'transparent',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 15,
|
||||
flexShrink: 0,
|
||||
background: active
|
||||
? overlayTheme.iconBg
|
||||
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'),
|
||||
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
}}
|
||||
>
|
||||
{group.icon}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 700 : 600,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{group.title}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
paddingInline: 6,
|
||||
borderRadius: 999,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: active
|
||||
? overlayTheme.selectedBg
|
||||
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'),
|
||||
color: active ? (darkMode ? '#f8fafc' : '#0f172a') : overlayTheme.mutedText,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={toolCenterContentPanelStyle}>
|
||||
{activeSettingsCenterPane ? (
|
||||
<div style={toolCenterDetailPanelStyle}>
|
||||
<div style={{ paddingBottom: 10, borderBottom: `1px solid ${overlayTheme.divider}` }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
|
||||
{activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title}
|
||||
</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>
|
||||
{activeSettingsCenterPaneItem?.description ?? activeSettingsCenterGroup.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={toolCenterDetailBodyStyle}>
|
||||
{renderSettingsCenterPane()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{activeSettingsCenterGroup.title}</div>
|
||||
<div style={utilityMutedTextStyle}>{activeSettingsCenterGroup.description}</div>
|
||||
</div>
|
||||
<div style={toolCenterScrollableListStyle}>
|
||||
{activeSettingsCenterGroup.items.map((item, index) => (
|
||||
<Button
|
||||
key={item.key}
|
||||
type="text"
|
||||
style={{
|
||||
...toolCenterRowStyle,
|
||||
borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none',
|
||||
borderBottom: `1px solid ${overlayTheme.divider}`,
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', minWidth: 0 }}>
|
||||
<span>{item.title}</span>
|
||||
<span style={toolCenterRowDescriptionStyle}>{item.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
<RightOutlined style={{ color: overlayTheme.mutedText, fontSize: 12, flexShrink: 0 }} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{isLanguageModalOpen && (
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<GlobalOutlined />, t('settings.language.title'), t('settings.language.description'))}
|
||||
open={isLanguageModalOpen}
|
||||
onCancel={() => setIsLanguageModalOpen(false)}
|
||||
footer={null}
|
||||
width={520}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<LanguageSettingsPanel />
|
||||
</Modal>
|
||||
)}
|
||||
{isDataRootModalOpen && (
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(
|
||||
@@ -5413,72 +5770,7 @@ function App() {
|
||||
width={520}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.proxy.section_title')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>{t('app.proxy.enable')}</span>
|
||||
<Switch checked={globalProxy.enabled} onChange={(checked) => setGlobalProxy({ enabled: checked })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, opacity: globalProxy.enabled ? 1 : 0.7 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.type')}</div>
|
||||
<Select
|
||||
value={globalProxy.type}
|
||||
disabled={!globalProxy.enabled}
|
||||
options={[
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
]}
|
||||
onChange={(value) => setGlobalProxy({ type: value as 'socks5' | 'http' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.port')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
value={globalProxy.port}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(value) => setGlobalProxy({
|
||||
port: typeof value === 'number' ? value : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.host')}</div>
|
||||
<Input
|
||||
placeholder={t('app.proxy.host_placeholder')}
|
||||
value={globalProxy.host}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.username_optional')}</div>
|
||||
<Input
|
||||
placeholder="proxy-user"
|
||||
value={globalProxy.user}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ user: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{t('app.proxy.password_optional')}</div>
|
||||
<Input.Password
|
||||
placeholder="proxy-password"
|
||||
value={globalProxy.password}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 6 }}>
|
||||
{t('app.proxy.scope_hint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderProxySettingsContent()}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1306,6 +1306,34 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(wideWidth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes table metadata suffixes when estimating v2 tree horizontal scroll width', () => {
|
||||
setCurrentLanguage('zh-CN');
|
||||
|
||||
const tableNode = [{
|
||||
title: 'orders',
|
||||
key: 'table-orders',
|
||||
type: 'table',
|
||||
dataRef: {
|
||||
tableComment: '订单归档明细按月分区',
|
||||
rowCount: 2_450_000,
|
||||
tableSize: 157_286_400,
|
||||
createdAt: '2026-07-01 08:30:00',
|
||||
updatedAt: '2026-07-02 09:45:00',
|
||||
},
|
||||
}];
|
||||
const viewportWidth = 360;
|
||||
const titleOnlyWidth = estimateV2TreeHorizontalScrollWidth(tableNode as any, viewportWidth, []);
|
||||
const metadataWidth = estimateV2TreeHorizontalScrollWidth(
|
||||
tableNode as any,
|
||||
viewportWidth,
|
||||
['comment', 'rows', 'size', 'createdAt', 'updatedAt'],
|
||||
);
|
||||
|
||||
expect(titleOnlyWidth).toBeUndefined();
|
||||
expect(metadataWidth).toBeGreaterThan(viewportWidth);
|
||||
expect(metadataWidth).toBeGreaterThan(titleOnlyWidth ?? viewportWidth);
|
||||
});
|
||||
|
||||
it('does not repeat the active connection as an object-tree root in v2', () => {
|
||||
mocks.state.connections = [{
|
||||
id: 'conn-local',
|
||||
@@ -2926,7 +2954,6 @@ describe('Sidebar locate toolbar', () => {
|
||||
dbName="mkefu_ai_dev"
|
||||
count={15}
|
||||
currentSort="frequency"
|
||||
showTableComments
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -2938,8 +2965,6 @@ describe('Sidebar locate toolbar', () => {
|
||||
sort: t('sidebar.v2_table_group_menu.sort_frequency'),
|
||||
}));
|
||||
expect(markup).toContain(t('sidebar.menu.create_table'));
|
||||
expect(markup).toContain(t('sidebar.v2_table_group_menu.display_section'));
|
||||
expect(markup).toContain(t('sidebar.v2_table_group_menu.show_table_comments'));
|
||||
expect(markup).toContain(t('data_grid.context_menu.sort_section'));
|
||||
expect(markup).toContain(t('sidebar.menu.sort_by_name'));
|
||||
expect(markup).toContain(t('sidebar.menu.sort_by_frequency'));
|
||||
@@ -2955,7 +2980,6 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(end).toBeGreaterThan(start);
|
||||
const tableGroupCallSource = sidebarSource.slice(start, end);
|
||||
expect(tableGroupCallSource).toContain('<V2TableGroupContextMenuView');
|
||||
expect(tableGroupCallSource).toContain('showTableComments={showSidebarTableComment}');
|
||||
expect(tableGroupCallSource).not.toContain('title=');
|
||||
['? ? tables', '表 · tables'].forEach((rawSnippet) => {
|
||||
expect(tableGroupCallSource).not.toContain(rawSnippet);
|
||||
@@ -3020,6 +3044,10 @@ describe('Sidebar locate toolbar', () => {
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
tableComment: '用户表',
|
||||
rowCount: 7,
|
||||
tableSize: 4096,
|
||||
createdAt: '2026-07-02 10:11:12',
|
||||
updatedAt: '2026-07-03 11:12:13',
|
||||
},
|
||||
};
|
||||
const baseOptions = {
|
||||
@@ -3036,18 +3064,23 @@ describe('Sidebar locate toolbar', () => {
|
||||
|
||||
const hiddenSuffixMarkup = renderToStaticMarkup(renderSidebarV2TreeTitle({
|
||||
...baseOptions,
|
||||
showSidebarTableComment: false,
|
||||
sidebarTableMetadataFields: ['rows'],
|
||||
}));
|
||||
expect(hiddenSuffixMarkup).not.toContain('gn-v2-tree-table-comment');
|
||||
|
||||
const visibleSuffixMarkup = renderToStaticMarkup(renderSidebarV2TreeTitle({
|
||||
...baseOptions,
|
||||
showSidebarTableComment: true,
|
||||
sidebarTableMetadataFields: ['comment', 'rows', 'size', 'createdAt', 'updatedAt'],
|
||||
}));
|
||||
expect(visibleSuffixMarkup).toContain('gn-v2-tree-table-comment');
|
||||
expect(visibleSuffixMarkup).toContain('用户表');
|
||||
expect(visibleSuffixMarkup).toContain(t('sidebar.v2_table_group_menu.metadata_value.rows', { count: '7' }));
|
||||
expect(visibleSuffixMarkup).toContain('4 KB');
|
||||
expect(visibleSuffixMarkup).toContain(t('sidebar.v2_table_group_menu.metadata_value.created_at', { time: '2026-07-02 10:11' }));
|
||||
expect(visibleSuffixMarkup).toContain(t('sidebar.v2_table_group_menu.metadata_value.updated_at', { time: '2026-07-03 11:12' }));
|
||||
|
||||
const treeTitleSource = readSourceFile('./sidebar/SidebarTreeTitle.tsx');
|
||||
const sidebarHelpersSource = readSourceFile('./sidebar/sidebarHelpers.ts');
|
||||
expect(treeTitleSource).toContain('data-sidebar-table-hover-info="true"');
|
||||
expect(treeTitleSource).toContain('rootClassName="gn-v2-tab-hover-tooltip gn-v2-sidebar-table-hover-tooltip"');
|
||||
expect(treeTitleSource).toContain('title={tableHoverInfo ? undefined : effectiveHoverTitle}');
|
||||
@@ -3059,6 +3092,10 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(treeTitleSource).toContain("t('tab_manager.kind_badge.table')");
|
||||
expect(treeTitleSource).toContain("t('tab_manager.hover.kind.table')");
|
||||
expect(treeTitleSource).toContain("t('table_designer.action.table_comment')");
|
||||
expect(sidebarHelpersSource).toContain("t('sidebar.v2_table_group_menu.metadata_value.rows'");
|
||||
expect(treeTitleSource).toContain("t('sidebar.v2_table_group_menu.display_table_size')");
|
||||
expect(treeTitleSource).toContain("t('sidebar.v2_table_group_menu.display_create_time')");
|
||||
expect(treeTitleSource).toContain("t('sidebar.v2_table_group_menu.display_update_time')");
|
||||
expect(treeTitleSource).toContain('mouseEnterDelay={1.2}');
|
||||
|
||||
const css = readV2ThemeCss();
|
||||
@@ -3076,13 +3113,24 @@ describe('Sidebar locate toolbar', () => {
|
||||
const oracleSql = buildSidebarTableStatusSQL({ config: { type: 'oracle' } } as any, 'APP');
|
||||
|
||||
expect(mysqlSql).toContain('TABLE_COMMENT AS table_comment');
|
||||
expect(mysqlSql).toContain('AS table_size');
|
||||
expect(mysqlSql).toContain('CREATE_TIME AS create_time');
|
||||
expect(pgSql).toContain("obj_description(c.oid, 'pg_class') AS table_comment");
|
||||
expect(pgSql).toContain('pg_total_relation_size(c.oid) AS table_size');
|
||||
expect(sqlServerSql).toContain('ep.value AS table_comment');
|
||||
expect(sqlServerSql).toContain('t.create_date AS create_time');
|
||||
expect(oracleSql).toContain('comments AS table_comment');
|
||||
expect(oracleSql).toContain('o.last_ddl_time AS update_time');
|
||||
|
||||
const loaderSource = readSourceFile('./sidebar/useSidebarTreeLoaders.tsx');
|
||||
expect(loaderSource).toContain('tableCommentMap');
|
||||
expect(loaderSource).toContain('tableComment: entry.tableComment');
|
||||
expect(loaderSource).toContain('tableMetadataMap');
|
||||
expect(loaderSource).toContain('resolvedMetadata?.tableComment');
|
||||
expect(loaderSource).toContain('tableSize: resolvedMetadata?.tableSize');
|
||||
expect(loaderSource).toContain('createdAt: resolvedMetadata?.createdAt');
|
||||
expect(loaderSource).toContain('updatedAt: resolvedMetadata?.updatedAt');
|
||||
expect(loaderSource).toContain('tableSize: entry.tableSize');
|
||||
expect(loaderSource).toContain('createdAt: entry.createdAt');
|
||||
expect(loaderSource).toContain('updatedAt: entry.updatedAt');
|
||||
});
|
||||
|
||||
it('listens for table overview pin changes to refresh the matching sidebar database node', () => {
|
||||
|
||||
@@ -142,6 +142,7 @@ import {
|
||||
import { useExportProgressDialog } from './ExportProgressModal';
|
||||
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
|
||||
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
|
||||
import { resolveSidebarTableMetadataFields } from '../utils/sidebarTableMetadata';
|
||||
import { t } from '../i18n';
|
||||
import MessagePublishModal from './MessagePublishModal';
|
||||
import {
|
||||
@@ -481,7 +482,13 @@ const Sidebar: React.FC<{
|
||||
const darkMode = theme === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const showSidebarTableComment = queryOptions?.showSidebarTableComment === true;
|
||||
const sidebarTableMetadataFields = useMemo(
|
||||
() => resolveSidebarTableMetadataFields(
|
||||
queryOptions?.sidebarTableMetadataFields,
|
||||
queryOptions?.showSidebarTableComment === true,
|
||||
),
|
||||
[queryOptions?.showSidebarTableComment, queryOptions?.sidebarTableMetadataFields],
|
||||
);
|
||||
const { exportProgressModal, runExportWithProgress } = useExportProgressDialog();
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
@@ -2102,8 +2109,6 @@ const Sidebar: React.FC<{
|
||||
moveConnectionToTag,
|
||||
setSidebarTablePinned,
|
||||
setTableSortPreference,
|
||||
setQueryOptions,
|
||||
showSidebarTableComment,
|
||||
replaceTreeNodeChildren,
|
||||
loadDatabases,
|
||||
loadTables,
|
||||
@@ -2166,6 +2171,7 @@ const Sidebar: React.FC<{
|
||||
v2CommandSearchValue,
|
||||
setV2CommandActiveIndex,
|
||||
v2ExplorerFilter,
|
||||
sidebarTableMetadataFields,
|
||||
treeData,
|
||||
treeViewportWidth,
|
||||
treeHeight,
|
||||
@@ -2231,7 +2237,6 @@ const Sidebar: React.FC<{
|
||||
v2TreeMetrics,
|
||||
tableSortPreference,
|
||||
pinnedSidebarTables,
|
||||
showSidebarTableComment,
|
||||
getConnectionNodeForAction,
|
||||
buildRuntimeConfig,
|
||||
extractObjectName,
|
||||
@@ -2256,7 +2261,7 @@ const Sidebar: React.FC<{
|
||||
hoverTitle,
|
||||
statusBadge,
|
||||
getV2TreeMetaText,
|
||||
showSidebarTableComment,
|
||||
sidebarTableMetadataFields,
|
||||
toggleSidebarTablePinned,
|
||||
snapshotTreeSelectionBeforeDrag,
|
||||
restoreTreeSelectionAfterDrag,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CheckSquareOutlined,
|
||||
CloudOutlined,
|
||||
ClearOutlined,
|
||||
ClockCircleOutlined,
|
||||
ColumnWidthOutlined,
|
||||
DashboardOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { getCurrentLanguage, t } from '../i18n';
|
||||
import { getPrimaryShortcutDisplayLabel, type ShortcutPlatform } from '../utils/shortcuts';
|
||||
import { formatSidebarTableSize } from './sidebar/sidebarHelpers';
|
||||
|
||||
export type V2TableContextMenuActionKey =
|
||||
| 'pin-table'
|
||||
@@ -88,11 +90,8 @@ export const formatV2TableContextMenuRows = (count?: number): string => {
|
||||
};
|
||||
|
||||
export const formatV2TableContextMenuSize = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || !Number.isFinite(bytes) || bytes < 0) return '—';
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
const formatted = bytes === undefined ? '' : formatSidebarTableSize(bytes);
|
||||
return formatted || '—';
|
||||
};
|
||||
|
||||
const resolveV2TableContextMenuMeta = (stats?: V2TableContextMenuStats): string => {
|
||||
@@ -264,7 +263,6 @@ export const V2TableContextMenuView: React.FC<{
|
||||
|
||||
export type V2TableGroupContextMenuActionKey =
|
||||
| 'new-table'
|
||||
| 'toggle-table-comments'
|
||||
| 'sort-by-name'
|
||||
| 'sort-by-frequency';
|
||||
|
||||
@@ -274,7 +272,6 @@ export const V2TableGroupContextMenuView: React.FC<{
|
||||
dbName?: string;
|
||||
count?: number;
|
||||
currentSort?: 'name' | 'frequency';
|
||||
showTableComments?: boolean;
|
||||
onAction?: (action: V2TableGroupContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
title,
|
||||
@@ -282,7 +279,6 @@ export const V2TableGroupContextMenuView: React.FC<{
|
||||
dbName,
|
||||
count,
|
||||
currentSort = 'name',
|
||||
showTableComments = false,
|
||||
onAction,
|
||||
}) => {
|
||||
const sortLabel = currentSort === 'frequency'
|
||||
@@ -313,17 +309,6 @@ export const V2TableGroupContextMenuView: React.FC<{
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: t('sidebar.menu.create_table'), kbd: primaryShortcut('N', shortcutPlatform), featured: true },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">{t('sidebar.v2_table_group_menu.display_section')}</div>
|
||||
{renderItems([
|
||||
{
|
||||
action: 'toggle-table-comments',
|
||||
icon: showTableComments ? <CheckSquareOutlined /> : <FileTextOutlined />,
|
||||
title: t('sidebar.v2_table_group_menu.show_table_comments'),
|
||||
kbd: showTableComments ? t('data_grid.context_menu.current_marker') : undefined,
|
||||
selected: showTableComments,
|
||||
},
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">{t('data_grid.context_menu.sort_section')}</div>
|
||||
{renderItems([
|
||||
{ action: 'sort-by-name', icon: currentSort === 'name' ? <CheckSquareOutlined /> : <ReloadOutlined />, title: t('sidebar.menu.sort_by_name'), kbd: currentSort === 'name' ? t('data_grid.context_menu.current_marker') : undefined, selected: currentSort === 'name' },
|
||||
|
||||
@@ -3,17 +3,28 @@ import { Tooltip } from 'antd';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import { t } from '../../i18n';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../../utils/sidebarSqlDrag';
|
||||
import {
|
||||
type SidebarTableMetadataField,
|
||||
type SidebarTableMetadataSnapshot,
|
||||
} from '../../utils/sidebarTableMetadata';
|
||||
import { sanitizeRedisDbAlias } from '../../utils/redisDbAlias';
|
||||
import { resolveConnectionHostSummary } from '../../utils/tabDisplay';
|
||||
import { resolveSidebarObjectDragText } from '../sidebarCoreUtils';
|
||||
import { resolveV2ObjectGroupTitle } from './sidebarHelpers';
|
||||
import {
|
||||
buildSidebarTableMetadataDisplayItems,
|
||||
buildSidebarTableMetadataSnapshot,
|
||||
formatSidebarRowCount,
|
||||
formatSidebarTableSize,
|
||||
formatSidebarTableTimestamp,
|
||||
resolveV2ObjectGroupTitle,
|
||||
} from './sidebarHelpers';
|
||||
|
||||
type SidebarV2TreeTitleOptions = {
|
||||
node: any;
|
||||
hoverTitle: string;
|
||||
statusBadge: React.ReactNode;
|
||||
getV2TreeMetaText: (node: any) => string;
|
||||
showSidebarTableComment: boolean;
|
||||
sidebarTableMetadataFields: SidebarTableMetadataField[];
|
||||
toggleSidebarTablePinned: (node: any) => void;
|
||||
snapshotTreeSelectionBeforeDrag: () => void;
|
||||
restoreTreeSelectionAfterDrag: () => void;
|
||||
@@ -42,7 +53,7 @@ const clearSidebarTableNativeHoverTitle = (event: React.SyntheticEvent<HTMLEleme
|
||||
const renderSidebarTableHoverInfo = (
|
||||
node: any,
|
||||
displayTitle: string,
|
||||
tableComment: string,
|
||||
metadata: SidebarTableMetadataSnapshot,
|
||||
): React.ReactNode => {
|
||||
const dataRef = node?.dataRef || {};
|
||||
const tableName = String(dataRef.tableName || displayTitle || node?.title || '').trim();
|
||||
@@ -57,7 +68,11 @@ const renderSidebarTableHoverInfo = (
|
||||
[t('tab_manager.hover.label.database'), dbName || t('tab_manager.hover.fallback.database_not_specified')],
|
||||
['Schema', schemaName],
|
||||
[t('tab_manager.hover.label.object'), tableName],
|
||||
[t('table_designer.action.table_comment'), tableComment],
|
||||
[t('table_designer.action.table_comment'), metadata.tableComment || ''],
|
||||
[t('sidebar.v2_table_group_menu.display_table_rows'), metadata.rowCount !== undefined ? formatSidebarRowCount(metadata.rowCount) : ''],
|
||||
[t('sidebar.v2_table_group_menu.display_table_size'), metadata.tableSize !== undefined ? formatSidebarTableSize(metadata.tableSize) : ''],
|
||||
[t('sidebar.v2_table_group_menu.display_create_time'), metadata.createdAt ? formatSidebarTableTimestamp(metadata.createdAt) : ''],
|
||||
[t('sidebar.v2_table_group_menu.display_update_time'), metadata.updatedAt ? formatSidebarTableTimestamp(metadata.updatedAt) : ''],
|
||||
].filter(([, value]) => Boolean(value));
|
||||
|
||||
return (
|
||||
@@ -100,7 +115,7 @@ export const renderSidebarV2TreeTitle = ({
|
||||
hoverTitle,
|
||||
statusBadge,
|
||||
getV2TreeMetaText,
|
||||
showSidebarTableComment,
|
||||
sidebarTableMetadataFields,
|
||||
toggleSidebarTablePinned,
|
||||
snapshotTreeSelectionBeforeDrag,
|
||||
restoreTreeSelectionAfterDrag,
|
||||
@@ -130,15 +145,17 @@ export const renderSidebarV2TreeTitle = ({
|
||||
}
|
||||
return rawTitle;
|
||||
})();
|
||||
const tableComment = node.type === 'table'
|
||||
? String(node?.dataRef?.tableComment || '').trim()
|
||||
: '';
|
||||
const tableCommentSuffix = showSidebarTableComment && tableComment ? tableComment : '';
|
||||
const tableMetadata = node.type === 'table'
|
||||
? buildSidebarTableMetadataSnapshot(node?.dataRef)
|
||||
: null;
|
||||
const tableMetadataItems = tableMetadata
|
||||
? buildSidebarTableMetadataDisplayItems(sidebarTableMetadataFields, tableMetadata)
|
||||
: [];
|
||||
const effectiveHoverTitle = hoverTitle;
|
||||
const tableHoverInfo = node.type === 'table'
|
||||
? renderSidebarTableHoverInfo(node, displayTitle, tableComment)
|
||||
? renderSidebarTableHoverInfo(node, displayTitle, tableMetadata ?? {})
|
||||
: null;
|
||||
const metaText = getV2TreeMetaText(node);
|
||||
const metaText = node.type === 'table' ? '' : getV2TreeMetaText(node);
|
||||
const redisDbAlias = node.type === 'redis-db'
|
||||
? sanitizeRedisDbAlias(node?.dataRef?.redisDbAlias)
|
||||
: '';
|
||||
@@ -248,9 +265,9 @@ export const renderSidebarV2TreeTitle = ({
|
||||
</>
|
||||
) : displayTitle}
|
||||
</span>
|
||||
{tableCommentSuffix && (
|
||||
<span className="gn-v2-tree-table-comment">{tableCommentSuffix}</span>
|
||||
)}
|
||||
{tableMetadataItems.map((item) => (
|
||||
<span key={item.key} className={item.className}>{item.text}</span>
|
||||
))}
|
||||
{metaText && <span className="gn-v2-tree-count">{metaText}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
// - 共享常量和类型集中管理,便于跨文件复用
|
||||
|
||||
import { t } from '../../i18n';
|
||||
import type {
|
||||
SidebarTableMetadataField,
|
||||
SidebarTableMetadataSnapshot,
|
||||
} from '../../utils/sidebarTableMetadata';
|
||||
|
||||
// === 共享常量 ===
|
||||
|
||||
@@ -36,6 +40,122 @@ export const formatSidebarRowCount = (count: number): string => {
|
||||
return String(Math.round(count));
|
||||
};
|
||||
|
||||
/**
|
||||
* formatSidebarTableSize 把字节数格式化为适合侧栏展示的短文本。
|
||||
*/
|
||||
export const formatSidebarTableSize = (bytes: number): string => {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '';
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const padSidebarTimestampPart = (value: number): string => String(value).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* formatSidebarTableTimestamp 把数据库返回的时间值统一压缩成 `YYYY-MM-DD HH:mm`。
|
||||
* 若值无法被 Date 可靠解析,则尽量保留原始文本的可读部分。
|
||||
*/
|
||||
export const formatSidebarTableTimestamp = (value: unknown): string => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
const normalizedText = text.replace('T', ' ').replace(/\.\d+$/, '');
|
||||
const parsed = new Date(text);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return normalizedText.length > 16 ? normalizedText.slice(0, 16) : normalizedText;
|
||||
}
|
||||
return [
|
||||
parsed.getFullYear(),
|
||||
'-',
|
||||
padSidebarTimestampPart(parsed.getMonth() + 1),
|
||||
'-',
|
||||
padSidebarTimestampPart(parsed.getDate()),
|
||||
' ',
|
||||
padSidebarTimestampPart(parsed.getHours()),
|
||||
':',
|
||||
padSidebarTimestampPart(parsed.getMinutes()),
|
||||
].join('');
|
||||
};
|
||||
|
||||
export interface SidebarTableMetadataDisplayItem {
|
||||
key: SidebarTableMetadataField;
|
||||
text: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export const buildSidebarTableMetadataSnapshot = (
|
||||
value: Partial<SidebarTableMetadataSnapshot> | null | undefined,
|
||||
): SidebarTableMetadataSnapshot => {
|
||||
const rowCount = Number(value?.rowCount);
|
||||
const tableSize = Number(value?.tableSize);
|
||||
const tableComment = String(value?.tableComment || '').trim();
|
||||
const createdAt = String(value?.createdAt || '').trim();
|
||||
const updatedAt = String(value?.updatedAt || '').trim();
|
||||
return {
|
||||
...(tableComment ? { tableComment } : {}),
|
||||
...(Number.isFinite(rowCount) && rowCount >= 0 ? { rowCount } : {}),
|
||||
...(Number.isFinite(tableSize) && tableSize >= 0 ? { tableSize } : {}),
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSidebarTableMetadataDisplayItems = (
|
||||
metadataFields: SidebarTableMetadataField[],
|
||||
snapshot: SidebarTableMetadataSnapshot,
|
||||
): SidebarTableMetadataDisplayItem[] => {
|
||||
const items: SidebarTableMetadataDisplayItem[] = [];
|
||||
if (metadataFields.includes('comment') && snapshot.tableComment) {
|
||||
items.push({
|
||||
key: 'comment',
|
||||
text: snapshot.tableComment,
|
||||
className: 'gn-v2-tree-table-comment',
|
||||
});
|
||||
}
|
||||
if (metadataFields.includes('rows') && snapshot.rowCount !== undefined) {
|
||||
const count = formatSidebarRowCount(snapshot.rowCount);
|
||||
if (count) {
|
||||
items.push({
|
||||
key: 'rows',
|
||||
text: t('sidebar.v2_table_group_menu.metadata_value.rows', { count }),
|
||||
className: 'gn-v2-tree-count',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (metadataFields.includes('size') && snapshot.tableSize !== undefined) {
|
||||
const size = formatSidebarTableSize(snapshot.tableSize);
|
||||
if (size) {
|
||||
items.push({
|
||||
key: 'size',
|
||||
text: t('sidebar.v2_table_group_menu.metadata_value.size', { size }),
|
||||
className: 'gn-v2-tree-count',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (metadataFields.includes('createdAt') && snapshot.createdAt) {
|
||||
const time = formatSidebarTableTimestamp(snapshot.createdAt);
|
||||
if (time) {
|
||||
items.push({
|
||||
key: 'createdAt',
|
||||
text: t('sidebar.v2_table_group_menu.metadata_value.created_at', { time }),
|
||||
className: 'gn-v2-tree-count',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (metadataFields.includes('updatedAt') && snapshot.updatedAt) {
|
||||
const time = formatSidebarTableTimestamp(snapshot.updatedAt);
|
||||
if (time) {
|
||||
items.push({
|
||||
key: 'updatedAt',
|
||||
text: t('sidebar.v2_table_group_menu.metadata_value.updated_at', { time }),
|
||||
className: 'gn-v2-tree-count',
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
/**
|
||||
* hasSidebarLazyChildren 判断树节点的 children 是否已加载(用于按需展开)。
|
||||
*/
|
||||
|
||||
@@ -262,7 +262,9 @@ const buildSidebarTableStatusSQL = (
|
||||
case "mysql":
|
||||
case "starrocks":
|
||||
return [
|
||||
"SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment, TABLE_ROWS AS table_rows",
|
||||
"SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment, TABLE_ROWS AS table_rows,",
|
||||
"COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) AS table_size,",
|
||||
"CREATE_TIME AS create_time, UPDATE_TIME AS update_time",
|
||||
"FROM information_schema.tables",
|
||||
`WHERE table_schema = '${safeDbName}'`,
|
||||
"AND table_type = 'BASE TABLE'",
|
||||
@@ -275,7 +277,8 @@ const buildSidebarTableStatusSQL = (
|
||||
case "opengauss":
|
||||
case "gaussdb":
|
||||
return [
|
||||
"SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment, c.reltuples::bigint AS table_rows",
|
||||
"SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment, c.reltuples::bigint AS table_rows,",
|
||||
"pg_total_relation_size(c.oid) AS table_size, NULL::text AS create_time, NULL::text AS update_time",
|
||||
"FROM pg_class c",
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace",
|
||||
"WHERE c.relkind = 'r'",
|
||||
@@ -286,19 +289,22 @@ const buildSidebarTableStatusSQL = (
|
||||
case "sqlserver": {
|
||||
const safeDb = quoteSqlServerIdentifier(dbName);
|
||||
return [
|
||||
"SELECT s.name + '.' + t.name AS table_name, ep.value AS table_comment, SUM(p.rows) AS table_rows",
|
||||
"SELECT s.name + '.' + t.name AS table_name, ep.value AS table_comment, SUM(p.rows) AS table_rows,",
|
||||
"SUM(a.total_pages) * 8 * 1024 AS table_size, t.create_date AS create_time, t.modify_date AS update_time",
|
||||
`FROM ${safeDb}.sys.tables t`,
|
||||
`JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id`,
|
||||
`LEFT JOIN ${safeDb}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'`,
|
||||
`LEFT JOIN ${safeDb}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)`,
|
||||
`LEFT JOIN ${safeDb}.sys.allocation_units a ON p.partition_id = a.container_id`,
|
||||
"WHERE t.type = 'U'",
|
||||
"GROUP BY s.name, t.name, ep.value",
|
||||
"GROUP BY s.name, t.name, ep.value, t.create_date, t.modify_date",
|
||||
"ORDER BY s.name, t.name",
|
||||
].join("\n");
|
||||
}
|
||||
case "clickhouse":
|
||||
return [
|
||||
"SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows",
|
||||
"SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows,",
|
||||
"total_bytes AS table_size, NULL AS create_time, metadata_modification_time AS update_time",
|
||||
"FROM system.tables",
|
||||
`WHERE database = '${safeDbName}'`,
|
||||
"AND engine NOT IN ('View', 'MaterializedView')",
|
||||
@@ -308,10 +314,15 @@ const buildSidebarTableStatusSQL = (
|
||||
case "dm": {
|
||||
const owner = escapeSQLLiteral(dbName).toUpperCase();
|
||||
return [
|
||||
"SELECT owner AS schema_name, table_name, comments AS table_comment, num_rows AS table_rows",
|
||||
"FROM all_tab_comments JOIN all_tables USING (table_name, owner)",
|
||||
`WHERE owner = '${owner}'`,
|
||||
"ORDER BY table_name",
|
||||
"SELECT c.owner AS schema_name, c.table_name, c.comments AS table_comment, t.num_rows AS table_rows,",
|
||||
"COALESCE(SUM(s.bytes), 0) AS table_size, o.created AS create_time, o.last_ddl_time AS update_time",
|
||||
"FROM all_tab_comments c",
|
||||
"JOIN all_tables t ON t.owner = c.owner AND t.table_name = c.table_name",
|
||||
"LEFT JOIN all_objects o ON o.owner = t.owner AND o.object_name = t.table_name AND o.object_type = 'TABLE'",
|
||||
"LEFT JOIN all_segments s ON s.owner = t.owner AND s.segment_name = t.table_name AND s.segment_type = 'TABLE'",
|
||||
`WHERE c.owner = '${owner}'`,
|
||||
"GROUP BY c.owner, c.table_name, c.comments, t.num_rows, o.created, o.last_ddl_time",
|
||||
"ORDER BY c.table_name",
|
||||
].join("\n");
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useStore } from '../../store';
|
||||
import type { SavedConnection } from '../../types';
|
||||
import { getCurrentLanguage, t } from '../../i18n';
|
||||
import { resolveShortcutDisplay } from '../../utils/shortcuts';
|
||||
import type { SidebarTableMetadataField } from '../../utils/sidebarTableMetadata';
|
||||
import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../../utils/tabDisplay';
|
||||
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../../utils/connectionVisual';
|
||||
import { getDbIcon } from '../DatabaseIcons';
|
||||
@@ -71,6 +72,7 @@ type SidebarSearchModelArgs = {
|
||||
v2CommandSearchValue: string;
|
||||
setV2CommandActiveIndex: Dispatch<SetStateAction<number>>;
|
||||
v2ExplorerFilter: V2ExplorerFilter;
|
||||
sidebarTableMetadataFields: SidebarTableMetadataField[];
|
||||
treeData: TreeNode[];
|
||||
treeViewportWidth: number;
|
||||
treeHeight: number;
|
||||
@@ -109,6 +111,7 @@ export const useSidebarSearchModel = ({
|
||||
v2CommandSearchValue,
|
||||
setV2CommandActiveIndex,
|
||||
v2ExplorerFilter,
|
||||
sidebarTableMetadataFields,
|
||||
treeData,
|
||||
treeViewportWidth,
|
||||
treeHeight,
|
||||
@@ -608,8 +611,12 @@ export const useSidebarSearchModel = ({
|
||||
return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter);
|
||||
}, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]);
|
||||
const v2TreeHorizontalScrollWidth = useMemo(
|
||||
() => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth),
|
||||
[treeViewportWidth, v2VisibleTreeData],
|
||||
() => estimateV2TreeHorizontalScrollWidth(
|
||||
v2VisibleTreeData,
|
||||
treeViewportWidth,
|
||||
sidebarTableMetadataFields,
|
||||
),
|
||||
[sidebarTableMetadataFields, treeViewportWidth, v2VisibleTreeData],
|
||||
);
|
||||
const effectiveTreeHeight = treeHeight;
|
||||
const v2TreeMetrics = useMemo(() => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type SidebarTreeNode as TreeNode,
|
||||
} from '../sidebarV2Utils';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities } from '../../../wailsjs/go/app/App';
|
||||
import type { SidebarTableMetadataSnapshot } from '../../utils/sidebarTableMetadata';
|
||||
|
||||
type DriverStatusSnapshot = {
|
||||
type: string;
|
||||
@@ -495,9 +496,7 @@ export const useSidebarTreeLoaders = ({
|
||||
const tableStatsResult = tableStatusSql
|
||||
? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] }))
|
||||
: { success: false, data: [] as any[] };
|
||||
const tableRowCountMap = new Map<string, number>();
|
||||
const tableCommentMap = new Map<string, string>();
|
||||
const tableSchemaMap = new Map<string, string>();
|
||||
const tableMetadataMap = new Map<string, SidebarTableMetadataSnapshot & { schemaName?: string }>();
|
||||
const buildTableMetadataKeys = (rawTableName: string, rawSchemaName = ''): string[] => {
|
||||
const tableName = String(rawTableName || '').trim();
|
||||
if (!tableName) return [];
|
||||
@@ -510,11 +509,33 @@ export const useSidebarTreeLoaders = ({
|
||||
if (qualifiedName) keys.add(qualifiedName.toLowerCase());
|
||||
return Array.from(keys);
|
||||
};
|
||||
const putTableComment = (rawTableName: string, rawComment: string, rawSchemaName = '') => {
|
||||
const comment = String(rawComment || '').trim();
|
||||
if (!comment) return;
|
||||
const readNumericMetadataValue = (row: Record<string, any>, keys: string[]): number | undefined => {
|
||||
const rawValue = getCaseInsensitiveValue(row, keys);
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') return undefined;
|
||||
const numericValue = Number(String(rawValue).replace(/,/g, ''));
|
||||
return Number.isFinite(numericValue) ? numericValue : undefined;
|
||||
};
|
||||
const normalizeMetadataTimestamp = (rawValue: unknown): string | undefined => {
|
||||
if (rawValue === undefined || rawValue === null) return undefined;
|
||||
const normalized = String(rawValue).trim();
|
||||
return normalized ? normalized : undefined;
|
||||
};
|
||||
const mergeTableMetadata = (
|
||||
rawTableName: string,
|
||||
patch: SidebarTableMetadataSnapshot & { schemaName?: string },
|
||||
rawSchemaName = '',
|
||||
) => {
|
||||
buildTableMetadataKeys(rawTableName, rawSchemaName).forEach((metadataKey) => {
|
||||
tableCommentMap.set(metadataKey, comment);
|
||||
const current = tableMetadataMap.get(metadataKey) || {};
|
||||
tableMetadataMap.set(metadataKey, {
|
||||
...current,
|
||||
...(patch.schemaName ? { schemaName: patch.schemaName } : {}),
|
||||
...(patch.tableComment ? { tableComment: patch.tableComment } : {}),
|
||||
...(patch.rowCount !== undefined ? { rowCount: patch.rowCount } : {}),
|
||||
...(patch.tableSize !== undefined ? { tableSize: patch.tableSize } : {}),
|
||||
...(patch.createdAt ? { createdAt: patch.createdAt } : {}),
|
||||
...(patch.updatedAt ? { updatedAt: patch.updatedAt } : {}),
|
||||
});
|
||||
});
|
||||
};
|
||||
if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) {
|
||||
@@ -526,12 +547,7 @@ export const useSidebarTreeLoaders = ({
|
||||
).trim();
|
||||
if (!rawTableName) return;
|
||||
const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'SCHEMA_NAME', 'owner', 'OWNER']);
|
||||
buildTableMetadataKeys(rawTableName, rawSchemaName).forEach((metadataKey) => {
|
||||
if (rawSchemaName) {
|
||||
tableSchemaMap.set(metadataKey, rawSchemaName);
|
||||
}
|
||||
});
|
||||
putTableComment(rawTableName, getCaseInsensitiveValue(row, [
|
||||
const tableComment = String(getCaseInsensitiveValue(row, [
|
||||
'table_comment',
|
||||
'TABLE_COMMENT',
|
||||
'comment',
|
||||
@@ -539,21 +555,54 @@ export const useSidebarTreeLoaders = ({
|
||||
'comments',
|
||||
'COMMENTS',
|
||||
'MS_Description',
|
||||
]), rawSchemaName);
|
||||
]) || '').trim();
|
||||
const rowCount = parseMetadataRowCount(row);
|
||||
if (rowCount === undefined) return;
|
||||
buildTableMetadataKeys(rawTableName, rawSchemaName).forEach((metadataKey) => {
|
||||
tableRowCountMap.set(metadataKey, rowCount);
|
||||
});
|
||||
const tableSize = readNumericMetadataValue(row, [
|
||||
'table_size',
|
||||
'TABLE_SIZE',
|
||||
'data_length',
|
||||
'DATA_LENGTH',
|
||||
'total_bytes',
|
||||
'TOTAL_BYTES',
|
||||
]);
|
||||
const createdAt = normalizeMetadataTimestamp(getCaseInsensitiveValue(row, [
|
||||
'create_time',
|
||||
'CREATE_TIME',
|
||||
'created_at',
|
||||
'CREATED_AT',
|
||||
'create_date',
|
||||
'CREATE_DATE',
|
||||
]));
|
||||
const updatedAt = normalizeMetadataTimestamp(getCaseInsensitiveValue(row, [
|
||||
'update_time',
|
||||
'UPDATE_TIME',
|
||||
'updated_at',
|
||||
'UPDATED_AT',
|
||||
'modify_date',
|
||||
'MODIFY_DATE',
|
||||
'last_ddl_time',
|
||||
'LAST_DDL_TIME',
|
||||
]));
|
||||
mergeTableMetadata(rawTableName, {
|
||||
schemaName: rawSchemaName ? String(rawSchemaName).trim() : undefined,
|
||||
...(tableComment ? { tableComment } : {}),
|
||||
...(rowCount !== undefined ? { rowCount } : {}),
|
||||
...(tableSize !== undefined ? { tableSize } : {}),
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
}, rawSchemaName);
|
||||
});
|
||||
}
|
||||
const tableEntries = tableRows.map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
const parsed = splitQualifiedName(tableName);
|
||||
const metadataKeys = buildTableMetadataKeys(tableName);
|
||||
const resolvedMetadata = metadataKeys
|
||||
.map((metadataKey) => tableMetadataMap.get(metadataKey))
|
||||
.find((value): value is SidebarTableMetadataSnapshot & { schemaName?: string } => !!value);
|
||||
const rowSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'SCHEMA_NAME', 'owner', 'OWNER']);
|
||||
const mappedSchemaName = rowSchemaName
|
||||
|| metadataKeys.map((metadataKey) => tableSchemaMap.get(metadataKey)).find((value): value is string => !!value)
|
||||
|| resolvedMetadata?.schemaName
|
||||
|| parsed.schemaName;
|
||||
const rowComment = getCaseInsensitiveValue(row, [
|
||||
'table_comment',
|
||||
@@ -567,13 +616,12 @@ export const useSidebarTreeLoaders = ({
|
||||
tableName,
|
||||
schemaName: mappedSchemaName,
|
||||
displayName: getSidebarTableDisplayName(conn, tableName),
|
||||
rowCount: metadataKeys
|
||||
.map((metadataKey) => tableRowCountMap.get(metadataKey))
|
||||
.find((value) => value !== undefined),
|
||||
rowCount: resolvedMetadata?.rowCount,
|
||||
tableSize: resolvedMetadata?.tableSize,
|
||||
createdAt: resolvedMetadata?.createdAt,
|
||||
updatedAt: resolvedMetadata?.updatedAt,
|
||||
tableComment: rowComment
|
||||
|| metadataKeys
|
||||
.map((metadataKey) => tableCommentMap.get(metadataKey))
|
||||
.find((value) => !!value)
|
||||
|| resolvedMetadata?.tableComment
|
||||
|| '',
|
||||
};
|
||||
});
|
||||
@@ -740,7 +788,16 @@ export const useSidebarTreeLoaders = ({
|
||||
|
||||
eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
|
||||
|
||||
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string; rowCount?: number; tableComment?: string }): TreeNode => {
|
||||
const buildTableNode = (entry: {
|
||||
tableName: string;
|
||||
schemaName: string;
|
||||
displayName: string;
|
||||
rowCount?: number;
|
||||
tableSize?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
tableComment?: string;
|
||||
}): TreeNode => {
|
||||
const isPinned = isV2Ui && isSidebarTablePinned(
|
||||
currentPinnedSidebarTables,
|
||||
conn.id,
|
||||
@@ -758,6 +815,9 @@ export const useSidebarTreeLoaders = ({
|
||||
tableName: entry.tableName,
|
||||
schemaName: entry.schemaName,
|
||||
rowCount: entry.rowCount,
|
||||
tableSize: entry.tableSize,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
tableComment: entry.tableComment,
|
||||
...(isPinned ? { pinnedSidebarTable: true } : {}),
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { FormInstance } from 'antd/es/form';
|
||||
import Modal from '../common/ResizableDraggableModal';
|
||||
import { t } from '../../i18n';
|
||||
import type { SavedConnection } from '../../types';
|
||||
import type { QueryOptions } from '../../store';
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../../utils/connectionVisual';
|
||||
import { buildTableSelectQuery } from '../../utils/objectQueryTemplates';
|
||||
@@ -56,8 +55,6 @@ type UseSidebarV2ActionHandlersArgs = {
|
||||
moveConnectionToTag: (connectionId: string, tagId: string | null) => void;
|
||||
setSidebarTablePinned: (connectionId: string, dbName: string, tableName: string, schemaName: string, pinned: boolean) => void;
|
||||
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
showSidebarTableComment: boolean;
|
||||
replaceTreeNodeChildren: (key: React.Key, children: TreeNode[] | undefined) => void;
|
||||
loadDatabases: (node: any) => Promise<void>;
|
||||
loadTables: (node: any) => Promise<void>;
|
||||
@@ -121,8 +118,6 @@ export const useSidebarV2ActionHandlers = ({
|
||||
moveConnectionToTag,
|
||||
setSidebarTablePinned,
|
||||
setTableSortPreference,
|
||||
setQueryOptions,
|
||||
showSidebarTableComment,
|
||||
replaceTreeNodeChildren,
|
||||
loadDatabases,
|
||||
loadTables,
|
||||
@@ -267,9 +262,6 @@ export const useSidebarV2ActionHandlers = ({
|
||||
case 'new-table':
|
||||
openNewTableDesign(node);
|
||||
return;
|
||||
case 'toggle-table-comments':
|
||||
setQueryOptions({ showSidebarTableComment: !showSidebarTableComment });
|
||||
return;
|
||||
case 'sort-by-name':
|
||||
handleTableGroupSortAction(node, 'name');
|
||||
return;
|
||||
|
||||
@@ -55,7 +55,6 @@ type SidebarV2ContextMenuOptions = {
|
||||
};
|
||||
tableSortPreference: Record<string, any>;
|
||||
pinnedSidebarTables: any[];
|
||||
showSidebarTableComment: boolean;
|
||||
getConnectionNodeForAction: (conn: SavedConnection) => TreeNode;
|
||||
buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any;
|
||||
extractObjectName: (fullName: string) => string;
|
||||
@@ -83,7 +82,6 @@ export const useSidebarV2ContextMenu = ({
|
||||
v2TreeMetrics,
|
||||
tableSortPreference,
|
||||
pinnedSidebarTables,
|
||||
showSidebarTableComment,
|
||||
getConnectionNodeForAction,
|
||||
buildRuntimeConfig,
|
||||
extractObjectName,
|
||||
@@ -313,7 +311,6 @@ export const useSidebarV2ContextMenu = ({
|
||||
dbName={String(groupData.dbName || '')}
|
||||
count={Array.isArray(node.children) ? node.children.length : 0}
|
||||
currentSort={currentSort}
|
||||
showTableComments={showSidebarTableComment}
|
||||
onAction={(action) => {
|
||||
setContextMenu(null);
|
||||
handleV2TableGroupContextMenuAction(node, action);
|
||||
|
||||
@@ -7,8 +7,13 @@ import {
|
||||
resolveSidebarRootOrderTokens,
|
||||
} from '../store';
|
||||
import type { ConnectionTag, SavedConnection } from '../types';
|
||||
import type { SidebarTableMetadataField } from '../utils/sidebarTableMetadata';
|
||||
import { t } from '../i18n';
|
||||
import { t as catalogTranslate } from '../i18n/catalog';
|
||||
import {
|
||||
buildSidebarTableMetadataDisplayItems,
|
||||
buildSidebarTableMetadataSnapshot,
|
||||
} from './sidebar/sidebarHelpers';
|
||||
|
||||
type SidebarV2Translate = (key: string) => string;
|
||||
|
||||
@@ -327,12 +332,15 @@ const V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH = 2600;
|
||||
const V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH = 88;
|
||||
const V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH = 24;
|
||||
const V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH = 8;
|
||||
const V2_TREE_HORIZONTAL_SCROLL_ITEM_GAP_WIDTH = 5;
|
||||
const V2_TREE_HORIZONTAL_SCROLL_COMMENT_MAX_CHARS = 32;
|
||||
const V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER = 48;
|
||||
export const V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE = 32;
|
||||
|
||||
export const estimateV2TreeHorizontalScrollWidth = (
|
||||
nodes: SidebarTreeNode[],
|
||||
viewportWidth: number,
|
||||
sidebarTableMetadataFields: SidebarTableMetadataField[] = [],
|
||||
): number | undefined => {
|
||||
const safeViewportWidth = Math.max(0, Math.ceil(viewportWidth || 0));
|
||||
let estimatedContentWidth = safeViewportWidth;
|
||||
@@ -340,12 +348,30 @@ export const estimateV2TreeHorizontalScrollWidth = (
|
||||
const visit = (items: SidebarTreeNode[], depth: number) => {
|
||||
items.forEach((node) => {
|
||||
const title = String(node?.title || '');
|
||||
const metaText = node?.dataRef?.groupKey === 'tables' && Array.isArray(node.children)
|
||||
? String(node.children.length)
|
||||
: '';
|
||||
const tableMetadataItems = node?.type === 'table'
|
||||
? buildSidebarTableMetadataDisplayItems(
|
||||
sidebarTableMetadataFields,
|
||||
buildSidebarTableMetadataSnapshot(node?.dataRef),
|
||||
)
|
||||
: [];
|
||||
const metaText = tableMetadataItems.length > 0
|
||||
? tableMetadataItems
|
||||
.map((item) => item.key === 'comment'
|
||||
? item.text.slice(0, V2_TREE_HORIZONTAL_SCROLL_COMMENT_MAX_CHARS)
|
||||
: item.text)
|
||||
.join('')
|
||||
: node?.dataRef?.groupKey === 'tables' && Array.isArray(node.children)
|
||||
? String(node.children.length)
|
||||
: '';
|
||||
const metaItemCount = tableMetadataItems.length > 0
|
||||
? tableMetadataItems.length
|
||||
: metaText
|
||||
? 1
|
||||
: 0;
|
||||
const nodeWidth = V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH
|
||||
+ (depth * V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH)
|
||||
+ ((title.length + metaText.length) * V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH);
|
||||
+ ((title.length + metaText.length) * V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH)
|
||||
+ (metaItemCount * V2_TREE_HORIZONTAL_SCROLL_ITEM_GAP_WIDTH);
|
||||
estimatedContentWidth = Math.max(estimatedContentWidth, nodeWidth);
|
||||
if (node.children?.length) {
|
||||
visit(node.children, depth + 1);
|
||||
|
||||
@@ -116,6 +116,29 @@ describe('store appearance persistence', () => {
|
||||
expect(appearance.v2SidebarRailScale).toBe(1.55);
|
||||
});
|
||||
|
||||
it('migrates legacy sidebar table comment settings into metadata fields and persists explicit selections', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
queryOptions: {
|
||||
showSidebarTableComment: true,
|
||||
},
|
||||
},
|
||||
version: 13,
|
||||
}));
|
||||
|
||||
const { useStore } = await importStore();
|
||||
expect(useStore.getState().queryOptions.sidebarTableMetadataFields).toEqual(['comment', 'rows']);
|
||||
expect(useStore.getState().queryOptions.showSidebarTableComment).toBe(true);
|
||||
|
||||
useStore.getState().setQueryOptions({
|
||||
sidebarTableMetadataFields: ['size', 'updatedAt'],
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.queryOptions.sidebarTableMetadataFields).toEqual(['size', 'updatedAt']);
|
||||
expect(persisted.state.queryOptions.showSidebarTableComment).toBe(false);
|
||||
});
|
||||
|
||||
it('restores query tabs from crash-recovery snapshots even when persisted tabs are missing', async () => {
|
||||
storage.setItem('gonavi-query-tab-drafts-v1', JSON.stringify([
|
||||
{
|
||||
|
||||
@@ -87,6 +87,11 @@ import {
|
||||
DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
|
||||
sanitizeQueryEditorEditorHeightRatio,
|
||||
} from "./utils/queryEditorSplitLayout";
|
||||
import {
|
||||
resolveSidebarTableMetadataFields,
|
||||
sanitizeSidebarTableMetadataFields,
|
||||
type SidebarTableMetadataField,
|
||||
} from "./utils/sidebarTableMetadata";
|
||||
|
||||
export type TableDoubleClickAction = "open-data" | "open-design";
|
||||
export type ThemeMode = "light" | "dark";
|
||||
@@ -1275,6 +1280,7 @@ export interface QueryOptions {
|
||||
maxRows: number;
|
||||
showColumnComment: boolean;
|
||||
showSidebarTableComment?: boolean;
|
||||
sidebarTableMetadataFields?: SidebarTableMetadataField[];
|
||||
showColumnType: boolean;
|
||||
showQueryResultsPanel: boolean;
|
||||
queryEditorEditorHeightRatio: number;
|
||||
@@ -2050,6 +2056,10 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||||
typeof raw.showSidebarTableComment === "boolean"
|
||||
? raw.showSidebarTableComment
|
||||
: false;
|
||||
const sidebarTableMetadataFields = Array.isArray(raw.sidebarTableMetadataFields)
|
||||
? sanitizeSidebarTableMetadataFields(raw.sidebarTableMetadataFields, [])
|
||||
: resolveSidebarTableMetadataFields(undefined, showSidebarTableComment);
|
||||
const derivedShowSidebarTableComment = sidebarTableMetadataFields.includes("comment");
|
||||
const showColumnType =
|
||||
typeof raw.showColumnType === "boolean" ? raw.showColumnType : true;
|
||||
const showQueryResultsPanel =
|
||||
@@ -2061,7 +2071,8 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||||
return {
|
||||
maxRows: 5000,
|
||||
showColumnComment,
|
||||
showSidebarTableComment,
|
||||
showSidebarTableComment: derivedShowSidebarTableComment,
|
||||
sidebarTableMetadataFields,
|
||||
showColumnType,
|
||||
showQueryResultsPanel,
|
||||
queryEditorEditorHeightRatio,
|
||||
@@ -2070,7 +2081,8 @@ const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||||
return {
|
||||
maxRows: Math.min(50000, Math.trunc(maxRows)),
|
||||
showColumnComment,
|
||||
showSidebarTableComment,
|
||||
showSidebarTableComment: derivedShowSidebarTableComment,
|
||||
sidebarTableMetadataFields,
|
||||
showColumnType,
|
||||
showQueryResultsPanel,
|
||||
queryEditorEditorHeightRatio,
|
||||
@@ -2507,6 +2519,7 @@ export const useStore = create<AppState>()(
|
||||
maxRows: 5000,
|
||||
showColumnComment: true,
|
||||
showSidebarTableComment: false,
|
||||
sidebarTableMetadataFields: ["rows"],
|
||||
showColumnType: true,
|
||||
showQueryResultsPanel: false,
|
||||
queryEditorEditorHeightRatio: DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO,
|
||||
@@ -3356,7 +3369,10 @@ export const useStore = create<AppState>()(
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) =>
|
||||
set((state) => ({
|
||||
queryOptions: { ...state.queryOptions, ...options },
|
||||
queryOptions: sanitizeQueryOptions({
|
||||
...state.queryOptions,
|
||||
...options,
|
||||
}),
|
||||
})),
|
||||
setDataEditTransactionOptions: (options) =>
|
||||
set((state) => ({
|
||||
|
||||
27
frontend/src/utils/sidebarTableMetadata.test.ts
Normal file
27
frontend/src/utils/sidebarTableMetadata.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveSidebarTableMetadataFields,
|
||||
sanitizeSidebarTableMetadataFields,
|
||||
setSidebarTableMetadataFieldSelected,
|
||||
} from './sidebarTableMetadata';
|
||||
|
||||
describe('sidebarTableMetadata', () => {
|
||||
it('keeps the default sidebar table metadata to row counts when no value is provided', () => {
|
||||
expect(resolveSidebarTableMetadataFields(undefined, false)).toEqual(['rows']);
|
||||
});
|
||||
|
||||
it('migrates the legacy sidebar table comment toggle into the metadata field list', () => {
|
||||
expect(resolveSidebarTableMetadataFields(undefined, true)).toEqual(['comment', 'rows']);
|
||||
});
|
||||
|
||||
it('preserves an explicit empty metadata selection while filtering unknown fields', () => {
|
||||
expect(sanitizeSidebarTableMetadataFields([], [])).toEqual([]);
|
||||
expect(sanitizeSidebarTableMetadataFields(['updatedAt', 'unknown', 'rows'], [])).toEqual(['rows', 'updatedAt']);
|
||||
});
|
||||
|
||||
it('toggles metadata fields in a canonical display order', () => {
|
||||
expect(setSidebarTableMetadataFieldSelected(['rows'], 'size', true)).toEqual(['rows', 'size']);
|
||||
expect(setSidebarTableMetadataFieldSelected(['comment', 'rows', 'size'], 'comment', false)).toEqual(['rows', 'size']);
|
||||
});
|
||||
});
|
||||
86
frontend/src/utils/sidebarTableMetadata.ts
Normal file
86
frontend/src/utils/sidebarTableMetadata.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const SIDEBAR_TABLE_METADATA_FIELDS = [
|
||||
'comment',
|
||||
'rows',
|
||||
'size',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const;
|
||||
|
||||
export type SidebarTableMetadataField =
|
||||
typeof SIDEBAR_TABLE_METADATA_FIELDS[number];
|
||||
|
||||
export interface SidebarTableMetadataSnapshot {
|
||||
tableComment?: string;
|
||||
rowCount?: number;
|
||||
tableSize?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS: SidebarTableMetadataField[] = [
|
||||
'rows',
|
||||
];
|
||||
|
||||
const isSidebarTableMetadataField = (
|
||||
value: unknown,
|
||||
): value is SidebarTableMetadataField =>
|
||||
typeof value === 'string'
|
||||
&& SIDEBAR_TABLE_METADATA_FIELDS.includes(
|
||||
value as SidebarTableMetadataField,
|
||||
);
|
||||
|
||||
const orderSidebarTableMetadataFields = (
|
||||
fields: Iterable<SidebarTableMetadataField>,
|
||||
): SidebarTableMetadataField[] => {
|
||||
const selected = new Set(fields);
|
||||
return SIDEBAR_TABLE_METADATA_FIELDS.filter((field) => selected.has(field));
|
||||
};
|
||||
|
||||
export const sanitizeSidebarTableMetadataFields = (
|
||||
value: unknown,
|
||||
fallback: SidebarTableMetadataField[] = DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS,
|
||||
): SidebarTableMetadataField[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return orderSidebarTableMetadataFields(fallback);
|
||||
}
|
||||
const normalized = orderSidebarTableMetadataFields(
|
||||
value.filter(isSidebarTableMetadataField),
|
||||
);
|
||||
if (normalized.length === 0) {
|
||||
return orderSidebarTableMetadataFields(fallback);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveSidebarTableMetadataFields = (
|
||||
value: unknown,
|
||||
legacyShowSidebarTableComment = false,
|
||||
): SidebarTableMetadataField[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return orderSidebarTableMetadataFields(
|
||||
value.filter(isSidebarTableMetadataField),
|
||||
);
|
||||
}
|
||||
const defaults = new Set<SidebarTableMetadataField>(
|
||||
DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS,
|
||||
);
|
||||
if (legacyShowSidebarTableComment) {
|
||||
defaults.add('comment');
|
||||
}
|
||||
return orderSidebarTableMetadataFields(defaults);
|
||||
};
|
||||
|
||||
export const setSidebarTableMetadataFieldSelected = (
|
||||
fields: SidebarTableMetadataField[],
|
||||
target: SidebarTableMetadataField,
|
||||
selected: boolean,
|
||||
): SidebarTableMetadataField[] => {
|
||||
const next = new Set(resolveSidebarTableMetadataFields(fields));
|
||||
if (selected) {
|
||||
next.add(target);
|
||||
} else {
|
||||
next.delete(target);
|
||||
}
|
||||
return orderSidebarTableMetadataFields(next);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user