From 68d4081fcf0b21968ff8bb6eacbf958ffc0e185f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 2 Jul 2026 12:54:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=A1=A8=E5=85=83=E6=95=B0=E6=8D=AE=E6=98=BE=E7=A4=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增侧栏表元数据字段配置并兼容旧版表备注开关 - 设置中心新增侧栏表元数据入口并移除树上重复显示开关 - 批量补齐表大小、创建时间、修改时间等元数据加载与渲染 - 统一表元数据格式化逻辑并修复 V2 左树横向滚动裁切 - 补充设置中心、store、侧栏元数据相关回归测试与多语言文案 --- frontend/src/App.settings-center.test.ts | 38 ++ frontend/src/App.tsx | 592 +++++++++++++----- .../Sidebar.locate-toolbar.test.tsx | 64 +- frontend/src/components/Sidebar.tsx | 15 +- .../src/components/V2TableContextMenu.tsx | 23 +- .../components/sidebar/SidebarTreeTitle.tsx | 45 +- .../src/components/sidebar/sidebarHelpers.ts | 120 ++++ .../sidebar/sidebarMetadataLoaders.ts | 29 +- .../sidebar/useSidebarSearchModel.tsx | 11 +- .../sidebar/useSidebarTreeLoaders.tsx | 112 +++- .../sidebar/useSidebarV2ActionHandlers.tsx | 8 - .../sidebar/useSidebarV2ContextMenu.tsx | 3 - frontend/src/components/sidebarV2Utils.ts | 34 +- frontend/src/store.test.ts | 23 + frontend/src/store.ts | 22 +- .../src/utils/sidebarTableMetadata.test.ts | 27 + frontend/src/utils/sidebarTableMetadata.ts | 86 +++ shared/i18n/de-DE.json | 16 + shared/i18n/en-US.json | 16 + shared/i18n/ja-JP.json | 16 + shared/i18n/ru-RU.json | 16 + shared/i18n/zh-CN.json | 16 + shared/i18n/zh-TW.json | 16 + 23 files changed, 1097 insertions(+), 251 deletions(-) create mode 100644 frontend/src/App.settings-center.test.ts create mode 100644 frontend/src/utils/sidebarTableMetadata.test.ts create mode 100644 frontend/src/utils/sidebarTableMetadata.ts diff --git a/frontend/src/App.settings-center.test.ts b/frontend/src/App.settings-center.test.ts new file mode 100644 index 00000000..44077338 --- /dev/null +++ b/frontend/src/App.settings-center.test.ts @@ -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('preferences');"); + expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState(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)"); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c2d8e91..8f794627 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [activeToolCenterPane, setActiveToolCenterPane] = useState(null); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false); + const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState('preferences'); + const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState(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(() => ( +
+
+
{t('app.proxy.section_title')}
+
+ {t('app.proxy.enable')} + setGlobalProxy({ enabled: checked })} /> +
+
+
+
{t('app.proxy.type')}
+ setGlobalProxy({ host: e.target.value })} + /> +
+
+
{t('app.proxy.username_optional')}
+ setGlobalProxy({ user: e.target.value })} + /> +
+
+
{t('app.proxy.password_optional')}
+ setGlobalProxy({ password: e.target.value })} + /> +
+
+
+ {t('app.proxy.scope_hint')} +
+
+
+ ), [darkMode, globalProxy.enabled, globalProxy.host, globalProxy.password, globalProxy.port, globalProxy.type, globalProxy.user, setGlobalProxy, t, utilityPanelStyle]); + const renderSidebarMetadataSettingsPane = useCallback(() => ( +
+
+
{t('app.settings.sidebar_metadata.title')}
+
+ {t('app.settings.sidebar_metadata.description')} +
+
+ {sidebarMetadataFieldItems.map((item) => { + const checked = sidebarTableMetadataFields.includes(item.field); + return ( +
+ {item.label} + toggleSidebarMetadataFieldFromSettings(item.field, selected)} + /> +
+ ); + })} +
+
+
+ +
+
+ ), [ + overlayTheme.divider, + overlayTheme.titleText, + setQueryOptions, + sidebarMetadataFieldItems, + sidebarTableMetadataFields, + t, + toggleSidebarMetadataFieldFromSettings, + utilityMutedTextStyle, + utilityPanelStyle, + ]); + const settingsCenterGroups = [ + { + key: 'preferences' as const, + icon: , + title: t('app.settings.group.preferences.title'), + description: t('app.settings.group.preferences.description'), + items: [ + { + key: 'language', + icon: , + title: t('settings.language.title'), + description: t('settings.language.description'), + onClick: () => handleOpenSettingsCenterPane('preferences', 'language'), + }, + { + key: 'theme', + icon: , + 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: , + title: t('app.settings.sidebar_metadata.title'), + description: t('app.settings.sidebar_metadata.description'), + onClick: () => handleOpenSettingsCenterPane('preferences', 'sidebar-metadata'), + }, + ], + }, + { + key: 'services' as const, + icon: , + title: t('app.settings.group.services.title'), + description: t('app.settings.group.services.description'), + items: [ + { + key: 'proxy', + icon: , + title: t('app.settings.entry.proxy.title'), + description: t('app.settings.entry.proxy.description'), + onClick: () => handleOpenSettingsCenterPane('services', 'proxy'), + }, + { + key: 'ai', + icon: , + title: t('app.settings.entry.ai.title'), + description: t('app.settings.entry.ai.description'), + onClick: () => { + setIsSettingsModalOpen(false); + handleOpenAISettings(); + }, + }, + ], + }, + { + key: 'about' as const, + icon: , + title: t('app.settings.group.about.title'), + description: t('app.settings.group.about.description'), + items: [ + { + key: 'about-go-navi', + icon: , + 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 ( +
+
+ +
+
+ ); + } + if (activeSettingsCenterPane.key === 'sidebar-metadata') { + return renderSidebarMetadataSettingsPane(); + } + if (activeSettingsCenterPane.key === 'proxy') { + return renderProxySettingsContent(); + } + return null; + }; return ( , 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 }, + }} > -
- {[ - { - key: 'language', - icon: , - title: t('settings.language.title'), - description: t('settings.language.description'), - onClick: () => { - setIsSettingsModalOpen(false); - setIsLanguageModalOpen(true); - }, - }, - { - key: 'theme', - icon: , - title: t('app.settings.entry.theme.title'), - description: t('app.settings.entry.theme.description'), - onClick: () => { - setIsSettingsModalOpen(false); - setThemeModalSection('theme'); - setIsThemeModalOpen(true); - }, - }, - { - key: 'proxy', - icon: , - title: t('app.settings.entry.proxy.title'), - description: t('app.settings.entry.proxy.description'), - onClick: () => { - setIsSettingsModalOpen(false); - setSecurityUpdateRepairSource(null); - setIsProxyModalOpen(true); - }, - }, - { - key: 'ai', - icon: , - title: t('app.settings.entry.ai.title'), - description: t('app.settings.entry.ai.description'), - onClick: () => { - setIsSettingsModalOpen(false); - handleOpenAISettings(); - }, - }, - { - key: 'about', - icon: , - title: t('app.settings.entry.about.title'), - description: t('app.settings.entry.about.description'), - onClick: () => { - setIsSettingsModalOpen(false); - setIsAboutOpen(true); - }, - }, - ].map((item) => ( - - ))} +
+
+
+
+ {settingsCenterGroups.map((group) => { + const active = group.key === activeSettingsCenterGroup.key; + return ( + + ); + })} +
+
+
+ {activeSettingsCenterPane ? ( +
+
+
+
+ {activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title} +
+
+ {activeSettingsCenterPaneItem?.description ?? activeSettingsCenterGroup.description} +
+
+
+
+ {renderSettingsCenterPane()} +
+
+ ) : ( + <> +
+
{activeSettingsCenterGroup.title}
+
{activeSettingsCenterGroup.description}
+
+
+ {activeSettingsCenterGroup.items.map((item, index) => ( + + ))} +
+ + )} +
+
)} - {isLanguageModalOpen && ( - , 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 } }} - > - - - )} {isDataRootModalOpen && ( -
-
-
{t('app.proxy.section_title')}
-
- {t('app.proxy.enable')} - setGlobalProxy({ enabled: checked })} /> -
-
-
-
{t('app.proxy.type')}
- setGlobalProxy({ host: e.target.value })} - /> -
-
-
{t('app.proxy.username_optional')}
- setGlobalProxy({ user: e.target.value })} - /> -
-
-
{t('app.proxy.password_optional')}
- setGlobalProxy({ password: e.target.value })} - /> -
-
-
- {t('app.proxy.scope_hint')} -
-
-
+ {renderProxySettingsContent()}
)} diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 9baeadcb..48939283 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -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(' { 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', () => { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3f061332..97e7443d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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, diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index d5b71eeb..7af3005c 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -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: , title: t('sidebar.menu.create_table'), kbd: primaryShortcut('N', shortcutPlatform), featured: true }, ])} -
{t('sidebar.v2_table_group_menu.display_section')}
- {renderItems([ - { - action: 'toggle-table-comments', - icon: showTableComments ? : , - title: t('sidebar.v2_table_group_menu.show_table_comments'), - kbd: showTableComments ? t('data_grid.context_menu.current_marker') : undefined, - selected: showTableComments, - }, - ])} -
{t('data_grid.context_menu.sort_section')}
{renderItems([ { action: 'sort-by-name', icon: currentSort === 'name' ? : , title: t('sidebar.menu.sort_by_name'), kbd: currentSort === 'name' ? t('data_grid.context_menu.current_marker') : undefined, selected: currentSort === 'name' }, diff --git a/frontend/src/components/sidebar/SidebarTreeTitle.tsx b/frontend/src/components/sidebar/SidebarTreeTitle.tsx index 061118c7..cd7e6cb9 100644 --- a/frontend/src/components/sidebar/SidebarTreeTitle.tsx +++ b/frontend/src/components/sidebar/SidebarTreeTitle.tsx @@ -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 { 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} - {tableCommentSuffix && ( - {tableCommentSuffix} - )} + {tableMetadataItems.map((item) => ( + {item.text} + ))} {metaText && {metaText}} ); diff --git a/frontend/src/components/sidebar/sidebarHelpers.ts b/frontend/src/components/sidebar/sidebarHelpers.ts index 69d40a31..9e4e2ea1 100644 --- a/frontend/src/components/sidebar/sidebarHelpers.ts +++ b/frontend/src/components/sidebar/sidebarHelpers.ts @@ -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 | 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 是否已加载(用于按需展开)。 */ diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts index 7013254c..5fe07bfd 100644 --- a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts @@ -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: diff --git a/frontend/src/components/sidebar/useSidebarSearchModel.tsx b/frontend/src/components/sidebar/useSidebarSearchModel.tsx index aa2871f8..2c09414b 100644 --- a/frontend/src/components/sidebar/useSidebarSearchModel.tsx +++ b/frontend/src/components/sidebar/useSidebarSearchModel.tsx @@ -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>; 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(() => { diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx index 9e535784..7e8a8d46 100644 --- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -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(); - const tableCommentMap = new Map(); - const tableSchemaMap = new Map(); + const tableMetadataMap = new Map(); 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, 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 } : {}), }, diff --git a/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx b/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx index 9e7b6df4..5dfbc02b 100644 --- a/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx +++ b/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx @@ -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) => void; - showSidebarTableComment: boolean; replaceTreeNodeChildren: (key: React.Key, children: TreeNode[] | undefined) => void; loadDatabases: (node: any) => Promise; loadTables: (node: any) => Promise; @@ -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; diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx index 4097483b..170a6ccc 100644 --- a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx +++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx @@ -55,7 +55,6 @@ type SidebarV2ContextMenuOptions = { }; tableSortPreference: Record; 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); diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts index 4eb449d9..d3c2e111 100644 --- a/frontend/src/components/sidebarV2Utils.ts +++ b/frontend/src/components/sidebarV2Utils.ts @@ -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); diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 9430991f..26a90a7c 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -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([ { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 2a7abb45..b673d11d 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -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()( 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()( setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }), setQueryOptions: (options) => set((state) => ({ - queryOptions: { ...state.queryOptions, ...options }, + queryOptions: sanitizeQueryOptions({ + ...state.queryOptions, + ...options, + }), })), setDataEditTransactionOptions: (options) => set((state) => ({ diff --git a/frontend/src/utils/sidebarTableMetadata.test.ts b/frontend/src/utils/sidebarTableMetadata.test.ts new file mode 100644 index 00000000..8e102b90 --- /dev/null +++ b/frontend/src/utils/sidebarTableMetadata.test.ts @@ -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']); + }); +}); diff --git a/frontend/src/utils/sidebarTableMetadata.ts b/frontend/src/utils/sidebarTableMetadata.ts new file mode 100644 index 00000000..7d004802 --- /dev/null +++ b/frontend/src/utils/sidebarTableMetadata.ts @@ -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[] => { + 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( + 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); +}; + diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index c9825523..5baf5bda 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2588,6 +2588,14 @@ "app.settings.entry.proxy.title": "Globaler Proxy", "app.settings.entry.theme.description": "Helles oder dunkles Theme wechseln und die Oberfläche anpassen.", "app.settings.entry.theme.title": "Theme und Darstellung", + "app.settings.group.about.description": "Versionsdetails, Repository-Links und weitere Produktinformationen.", + "app.settings.group.about.title": "Info", + "app.settings.group.preferences.description": "Sprache, Erscheinungsbild und Sidebar-Anzeige zentral verwalten.", + "app.settings.group.preferences.title": "Einstellungen", + "app.settings.group.services.description": "Netzwerkzugang und KI-bezogene Dienste konfigurieren.", + "app.settings.group.services.title": "Dienste", + "app.settings.sidebar_metadata.description": "Festlegen, welche Tabellenmetadaten neben Tabellennamen in der linken Sidebar angezeigt werden.", + "app.settings.sidebar_metadata.title": "Sidebar-Tabellenmetadaten", "app.settings.title": "Einstellungscenter", "app.shortcuts.action.diagnoseQuery.description": "EXPLAIN für die aktuelle SQL ausführen und Ausführungsplan mit Indexvorschlägen anzeigen", "app.shortcuts.action.diagnoseQuery.label": "SQL-Diagnose", @@ -7151,7 +7159,15 @@ "sidebar.v2_schema_menu.meta": "{{database}} · Schema-Aktionen", "sidebar.v2_table_group_menu.current_database": "Aktuelle Datenbank", "sidebar.v2_table_group_menu.display_section": "Anzeige", + "sidebar.v2_table_group_menu.display_create_time": "Erstellungszeit anzeigen", + "sidebar.v2_table_group_menu.display_table_rows": "Zeilenzahl anzeigen", + "sidebar.v2_table_group_menu.display_table_size": "Tabellengröße anzeigen", + "sidebar.v2_table_group_menu.display_update_time": "Änderungszeit anzeigen", "sidebar.v2_table_group_menu.meta": "{{database}} · {{count}} Tabellen · nach {{sort}} sortiert", + "sidebar.v2_table_group_menu.metadata_value.created_at": "Erstellt {{time}}", + "sidebar.v2_table_group_menu.metadata_value.rows": "{{count}} Zeilen", + "sidebar.v2_table_group_menu.metadata_value.size": "{{size}}", + "sidebar.v2_table_group_menu.metadata_value.updated_at": "Geändert {{time}}", "sidebar.v2_table_group_menu.show_table_comments": "Tabellenkommentare anzeigen", "sidebar.v2_table_group_menu.sort_frequency": "Nutzungshäufigkeit", "sidebar.v2_table_group_menu.sort_name": "Name", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 2312579f..c259ed21 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2588,6 +2588,14 @@ "app.settings.entry.proxy.title": "Global Proxy", "app.settings.entry.theme.description": "Switch light or dark theme and adjust interface appearance.", "app.settings.entry.theme.title": "Theme and Appearance", + "app.settings.group.about.description": "Version details, repository links, and other product information.", + "app.settings.group.about.title": "About", + "app.settings.group.preferences.description": "Language, appearance, and sidebar display preferences.", + "app.settings.group.preferences.title": "Preferences", + "app.settings.group.services.description": "Network access and AI-related service configuration.", + "app.settings.group.services.title": "Services", + "app.settings.sidebar_metadata.description": "Choose which table metadata appears beside table names in the left sidebar.", + "app.settings.sidebar_metadata.title": "Sidebar Table Metadata", "app.settings.title": "Settings Center", "app.shortcuts.action.diagnoseQuery.description": "Run EXPLAIN on the current SQL and visualize the execution plan with index suggestions", "app.shortcuts.action.diagnoseQuery.label": "SQL Diagnose", @@ -7151,7 +7159,15 @@ "sidebar.v2_schema_menu.meta": "{{database}} · Schema actions", "sidebar.v2_table_group_menu.current_database": "Current database", "sidebar.v2_table_group_menu.display_section": "Display", + "sidebar.v2_table_group_menu.display_create_time": "Show created time", + "sidebar.v2_table_group_menu.display_table_rows": "Show row counts", + "sidebar.v2_table_group_menu.display_table_size": "Show table size", + "sidebar.v2_table_group_menu.display_update_time": "Show updated time", "sidebar.v2_table_group_menu.meta": "{{database}} · {{count}} tables · sorted by {{sort}}", + "sidebar.v2_table_group_menu.metadata_value.created_at": "Created {{time}}", + "sidebar.v2_table_group_menu.metadata_value.rows": "{{count}} rows", + "sidebar.v2_table_group_menu.metadata_value.size": "{{size}}", + "sidebar.v2_table_group_menu.metadata_value.updated_at": "Updated {{time}}", "sidebar.v2_table_group_menu.show_table_comments": "Show table comments", "sidebar.v2_table_group_menu.sort_frequency": "usage frequency", "sidebar.v2_table_group_menu.sort_name": "name", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index c9e63521..853aac95 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2588,6 +2588,14 @@ "app.settings.entry.proxy.title": "グローバルプロキシ", "app.settings.entry.theme.description": "ライト/ダークテーマを切り替え、表示の見た目を調整します。", "app.settings.entry.theme.title": "テーマと外観", + "app.settings.group.about.description": "バージョン情報、リポジトリリンク、そのほか製品情報を確認します。", + "app.settings.group.about.title": "情報", + "app.settings.group.preferences.description": "言語、外観、サイドバー表示の設定をまとめます。", + "app.settings.group.preferences.title": "基本設定", + "app.settings.group.services.description": "ネットワークアクセスと AI サービス関連の設定です。", + "app.settings.group.services.title": "サービス", + "app.settings.sidebar_metadata.description": "左サイドバーのテーブル名の横に表示するメタデータを選択します。", + "app.settings.sidebar_metadata.title": "サイドバーテーブルメタデータ", "app.settings.title": "設定センター", "app.shortcuts.action.diagnoseQuery.description": "現在の SQL に対して EXPLAIN を実行し、実行計画図とインデックス提案を表示します", "app.shortcuts.action.diagnoseQuery.label": "SQL 診断", @@ -7151,7 +7159,15 @@ "sidebar.v2_schema_menu.meta": "{{database}} · スキーマ操作", "sidebar.v2_table_group_menu.current_database": "現在のデータベース", "sidebar.v2_table_group_menu.display_section": "表示", + "sidebar.v2_table_group_menu.display_create_time": "作成時刻を表示", + "sidebar.v2_table_group_menu.display_table_rows": "行数を表示", + "sidebar.v2_table_group_menu.display_table_size": "テーブルサイズを表示", + "sidebar.v2_table_group_menu.display_update_time": "更新時刻を表示", "sidebar.v2_table_group_menu.meta": "{{database}} · {{count}} テーブル · {{sort}}順で並べ替え中", + "sidebar.v2_table_group_menu.metadata_value.created_at": "作成 {{time}}", + "sidebar.v2_table_group_menu.metadata_value.rows": "{{count}} 行", + "sidebar.v2_table_group_menu.metadata_value.size": "{{size}}", + "sidebar.v2_table_group_menu.metadata_value.updated_at": "更新 {{time}}", "sidebar.v2_table_group_menu.show_table_comments": "テーブルコメントを表示", "sidebar.v2_table_group_menu.sort_frequency": "使用頻度", "sidebar.v2_table_group_menu.sort_name": "名前", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 0c4592ad..98436e46 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2588,6 +2588,14 @@ "app.settings.entry.proxy.title": "Глобальный прокси", "app.settings.entry.theme.description": "Переключение светлой или темной темы и настройка внешнего вида интерфейса.", "app.settings.entry.theme.title": "Тема и внешний вид", + "app.settings.group.about.description": "Сведения о версии, ссылки на репозиторий и другая информация о продукте.", + "app.settings.group.about.title": "О приложении", + "app.settings.group.preferences.description": "Управление языком, внешним видом и отображением боковой панели.", + "app.settings.group.preferences.title": "Предпочтения", + "app.settings.group.services.description": "Настройки сетевого доступа и сервисов, связанных с AI.", + "app.settings.group.services.title": "Сервисы", + "app.settings.sidebar_metadata.description": "Выберите, какие метаданные таблиц показывать рядом с именами таблиц в левом дереве.", + "app.settings.sidebar_metadata.title": "Метаданные таблиц в боковой панели", "app.settings.title": "Центр настроек", "app.shortcuts.action.diagnoseQuery.description": "Выполнить EXPLAIN для текущего SQL и показать план выполнения с предложениями индексов", "app.shortcuts.action.diagnoseQuery.label": "SQL-диагностика", @@ -7151,7 +7159,15 @@ "sidebar.v2_schema_menu.meta": "{{database}} · Действия со схемой", "sidebar.v2_table_group_menu.current_database": "Текущая база данных", "sidebar.v2_table_group_menu.display_section": "Отображение", + "sidebar.v2_table_group_menu.display_create_time": "Показывать время создания", + "sidebar.v2_table_group_menu.display_table_rows": "Показывать число строк", + "sidebar.v2_table_group_menu.display_table_size": "Показывать размер таблицы", + "sidebar.v2_table_group_menu.display_update_time": "Показывать время изменения", "sidebar.v2_table_group_menu.meta": "{{database}} · {{count}} таблиц · сортировка по {{sort}}", + "sidebar.v2_table_group_menu.metadata_value.created_at": "Создана {{time}}", + "sidebar.v2_table_group_menu.metadata_value.rows": "{{count}} строк", + "sidebar.v2_table_group_menu.metadata_value.size": "{{size}}", + "sidebar.v2_table_group_menu.metadata_value.updated_at": "Изменена {{time}}", "sidebar.v2_table_group_menu.show_table_comments": "Показывать комментарии таблиц", "sidebar.v2_table_group_menu.sort_frequency": "частоте использования", "sidebar.v2_table_group_menu.sort_name": "имени", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index f4903e64..1ec14197 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2588,6 +2588,14 @@ "app.settings.entry.proxy.title": "全局代理", "app.settings.entry.theme.description": "切换亮暗主题并调整界面观感。", "app.settings.entry.theme.title": "主题与外观", + "app.settings.group.about.description": "查看版本信息、仓库地址和其他产品信息。", + "app.settings.group.about.title": "关于", + "app.settings.group.preferences.description": "统一管理语言、外观和侧栏显示偏好。", + "app.settings.group.preferences.title": "偏好设置", + "app.settings.group.services.description": "统一管理网络访问和 AI 相关服务配置。", + "app.settings.group.services.title": "服务配置", + "app.settings.sidebar_metadata.description": "选择左侧树中表名后需要展示哪些元数据。", + "app.settings.sidebar_metadata.title": "侧栏表元数据", "app.settings.title": "设置中心", "app.shortcuts.action.diagnoseQuery.description": "对当前 SQL 执行 EXPLAIN 并展示执行计划图与索引建议", "app.shortcuts.action.diagnoseQuery.label": "SQL 诊断", @@ -7151,7 +7159,15 @@ "sidebar.v2_schema_menu.meta": "{{database}} · 模式操作", "sidebar.v2_table_group_menu.current_database": "当前数据库", "sidebar.v2_table_group_menu.display_section": "显示", + "sidebar.v2_table_group_menu.display_create_time": "显示创建时间", + "sidebar.v2_table_group_menu.display_table_rows": "显示表行数", + "sidebar.v2_table_group_menu.display_table_size": "显示表大小", + "sidebar.v2_table_group_menu.display_update_time": "显示修改时间", "sidebar.v2_table_group_menu.meta": "{{database}} · {{count}} 张表 · 当前按{{sort}}排序", + "sidebar.v2_table_group_menu.metadata_value.created_at": "建 {{time}}", + "sidebar.v2_table_group_menu.metadata_value.rows": "{{count}}行", + "sidebar.v2_table_group_menu.metadata_value.size": "{{size}}", + "sidebar.v2_table_group_menu.metadata_value.updated_at": "改 {{time}}", "sidebar.v2_table_group_menu.show_table_comments": "显示表备注", "sidebar.v2_table_group_menu.sort_frequency": "使用频率", "sidebar.v2_table_group_menu.sort_name": "名称", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 53fb2312..b230eab2 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2588,6 +2588,14 @@ "app.settings.entry.proxy.title": "全局代理", "app.settings.entry.theme.description": "切换亮暗主題并调整界面观感。", "app.settings.entry.theme.title": "主題与外观", + "app.settings.group.about.description": "檢視版本資訊、倉庫位址和其他產品資訊。", + "app.settings.group.about.title": "關於", + "app.settings.group.preferences.description": "統一管理語言、外觀和側欄顯示偏好。", + "app.settings.group.preferences.title": "偏好設定", + "app.settings.group.services.description": "統一管理網路存取與 AI 相關服務設定。", + "app.settings.group.services.title": "服務設定", + "app.settings.sidebar_metadata.description": "選擇左側樹中表名後要顯示哪些中介資料。", + "app.settings.sidebar_metadata.title": "側欄表格中介資料", "app.settings.title": "設定中心", "app.shortcuts.action.diagnoseQuery.description": "對目前 SQL 執行 EXPLAIN 並顯示執行計劃圖與索引建議", "app.shortcuts.action.diagnoseQuery.label": "SQL 診斷", @@ -7151,7 +7159,15 @@ "sidebar.v2_schema_menu.meta": "{{database}} · 模式操作", "sidebar.v2_table_group_menu.current_database": "目前資料庫", "sidebar.v2_table_group_menu.display_section": "顯示", + "sidebar.v2_table_group_menu.display_create_time": "顯示建立時間", + "sidebar.v2_table_group_menu.display_table_rows": "顯示資料列數", + "sidebar.v2_table_group_menu.display_table_size": "顯示資料表大小", + "sidebar.v2_table_group_menu.display_update_time": "顯示修改時間", "sidebar.v2_table_group_menu.meta": "{{database}} · {{count}} 張資料表 · 目前依{{sort}}排序", + "sidebar.v2_table_group_menu.metadata_value.created_at": "建 {{time}}", + "sidebar.v2_table_group_menu.metadata_value.rows": "{{count}}列", + "sidebar.v2_table_group_menu.metadata_value.size": "{{size}}", + "sidebar.v2_table_group_menu.metadata_value.updated_at": "改 {{time}}", "sidebar.v2_table_group_menu.show_table_comments": "顯示資料表備註", "sidebar.v2_table_group_menu.sort_frequency": "使用頻率", "sidebar.v2_table_group_menu.sort_name": "名稱",