feat(sidebar): 支持自定义对象分类显示

新增侧栏对象分类开关与仅显示表快捷设置
配置持久化并即时应用到所有连接
补充筛选、持久化与国际化回归测试
This commit is contained in:
Syngnat
2026-07-14 21:53:57 +08:00
parent 3fa4f8e900
commit a0bb19b8bb
14 changed files with 323 additions and 4 deletions

View File

@@ -15,7 +15,7 @@ const aiSettingsModalSource = readFileSync(
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("type SettingsCenterPaneKey = 'language' | 'theme' | 'sidebar-metadata' | 'proxy' | 'web-auth' | 'ai' | 'about-go-navi';");
expect(appSource).toContain("type SettingsCenterPaneKey = 'language' | 'theme' | 'sidebar-metadata' | 'sidebar-objects' | 'proxy' | 'web-auth' | 'ai' | 'about-go-navi';");
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
@@ -47,6 +47,17 @@ describe('settings center layout', () => {
expect(appSource).not.toContain("setIsLanguageModalOpen(true)");
});
it('adds persistent sidebar object visibility controls to preferences', () => {
expect(appSource).toContain("key: 'sidebar-objects'");
expect(appSource).toContain("title: t('app.settings.sidebar_objects.title')");
expect(appSource).toContain("description: t('app.settings.sidebar_objects.description')");
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-objects')");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'sidebar-objects')");
expect(appSource).toContain('renderSidebarObjectVisibilitySettingsPane();');
expect(appSource).toContain('sidebarHiddenObjectGroups');
expect(appSource).toContain('SIDEBAR_OBJECT_GROUP_KEYS.filter((key) => key !== \'tables\')');
});
it('adds browser auth management into the services settings group', () => {
expect(appSource).toContain("key: 'web-auth' as const");
expect(appSource).toContain("title: t('app.settings.entry.web_auth.title')");

View File

@@ -105,6 +105,10 @@ import {
setSidebarTableMetadataFieldSelected,
type SidebarTableMetadataField,
} from './utils/sidebarTableMetadata';
import {
SIDEBAR_OBJECT_GROUP_KEYS,
type SidebarObjectGroupKey,
} from './utils/sidebarObjectVisibility';
import {
getSecurityUpdateStatusMeta,
resolveSecurityUpdateEntryVisibility,
@@ -465,7 +469,7 @@ type ToolCenterPaneState = {
};
type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';
type SettingsCenterPaneKey = 'language' | 'theme' | 'sidebar-metadata' | 'proxy' | 'web-auth' | 'ai' | 'about-go-navi';
type SettingsCenterPaneKey = 'language' | 'theme' | 'sidebar-metadata' | 'sidebar-objects' | 'proxy' | 'web-auth' | 'ai' | 'about-go-navi';
type SettingsCenterPaneState = {
key: SettingsCenterPaneKey;
group: SettingsCenterGroupKey;
@@ -4415,6 +4419,81 @@ function App() {
utilityMutedTextStyle,
utilityPanelStyle,
]);
const renderSidebarObjectVisibilitySettingsPane = useCallback(() => {
const hiddenObjectGroups = new Set(appearance.sidebarHiddenObjectGroups);
const objectGroupItems: Array<{ key: SidebarObjectGroupKey; label: string }> = [
{ key: 'savedQueries', label: t('sidebar.tree.saved_queries') },
{ key: 'tables', label: t('sidebar.object_group.tables') },
{ key: 'views', label: t('sidebar.object_group.views') },
{ key: 'materializedViews', label: t('sidebar.object_group.materialized_views') },
{ key: 'routines', label: t('sidebar.object_group.routines') },
{ key: 'triggers', label: t('sidebar.object_group.triggers') },
{ key: 'events', label: t('sidebar.object_group.events') },
{ key: 'sequences', label: t('sidebar.object_group.sequences') },
{ key: 'packages', label: t('sidebar.object_group.packages') },
];
const setObjectGroupVisible = (key: SidebarObjectGroupKey, visible: boolean) => {
const nextHiddenObjectGroups = visible
? appearance.sidebarHiddenObjectGroups.filter((item) => item !== key)
: Array.from(new Set([...appearance.sidebarHiddenObjectGroups, key]));
setAppearance({ sidebarHiddenObjectGroups: nextHiddenObjectGroups });
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('app.settings.sidebar_objects.title')}</div>
<div style={{ ...utilityMutedTextStyle, marginBottom: 14 }}>
{t('app.settings.sidebar_objects.description')}
</div>
<div style={{ display: 'grid', gap: 8 }}>
{objectGroupItems.map((item) => (
<div
key={item.key}
data-sidebar-object-group-setting={item.key}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
minHeight: 36,
padding: '0 2px',
borderBottom: `1px solid ${overlayTheme.divider}`,
}}
>
<span>{item.label}</span>
<Switch
checked={!hiddenObjectGroups.has(item.key)}
aria-label={item.label}
onChange={(visible) => setObjectGroupVisible(item.key, visible)}
/>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
<Button onClick={() => setAppearance({ sidebarHiddenObjectGroups: [] })}>
{t('app.settings.sidebar_objects.action.show_all')}
</Button>
<Button
type="primary"
onClick={() => setAppearance({
sidebarHiddenObjectGroups: SIDEBAR_OBJECT_GROUP_KEYS.filter((key) => key !== 'tables'),
})}
>
{t('app.settings.sidebar_objects.action.tables_only')}
</Button>
</div>
</div>
</div>
);
}, [
appearance.sidebarHiddenObjectGroups,
overlayTheme.divider,
setAppearance,
t,
utilityMutedTextStyle,
utilityPanelStyle,
]);
const updateInstallActionLabel = updateInstallAction === 'install-and-restart'
? t('app.about.action.install_and_restart')
: (updateInstallAction === 'launch-installer'
@@ -6431,6 +6510,13 @@ function App() {
description: t('app.settings.sidebar_metadata.description'),
onClick: () => handleOpenSettingsCenterPane('preferences', 'sidebar-metadata'),
},
{
key: 'sidebar-objects',
icon: <FolderOpenOutlined />,
title: t('app.settings.sidebar_objects.title'),
description: t('app.settings.sidebar_objects.description'),
onClick: () => handleOpenSettingsCenterPane('preferences', 'sidebar-objects'),
},
],
},
{
@@ -6524,6 +6610,9 @@ function App() {
if (activeSettingsCenterPane.key === 'sidebar-metadata') {
return renderSidebarMetadataSettingsPane();
}
if (activeSettingsCenterPane.key === 'sidebar-objects') {
return renderSidebarObjectVisibilitySettingsPane();
}
if (activeSettingsCenterPane.key === 'proxy') {
return renderProxySettingsContent();
}

View File

@@ -160,6 +160,7 @@ import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import { resolveSidebarTableMetadataFields } from '../utils/sidebarTableMetadata';
import { filterSidebarTreeByHiddenObjectGroups } from '../utils/sidebarObjectVisibility';
import { t } from '../i18n';
import MessagePublishModal from './MessagePublishModal';
import {
@@ -687,6 +688,12 @@ const Sidebar: React.FC<{
const allSavedQueriesNode = useMemo<TreeNode | null>(() => {
return buildAllSavedQueriesTreeNode(savedQueries, connections, savedQueryGroups);
}, [connections, savedQueries, savedQueryGroups]);
const sidebarHiddenObjectGroups = appearance.sidebarHiddenObjectGroups;
const visibleSidebarTreeData = useMemo(
() => filterSidebarTreeByHiddenObjectGroups(treeData, sidebarHiddenObjectGroups),
[sidebarHiddenObjectGroups, treeData],
);
const sidebarObjectVisibilitySignature = sidebarHiddenObjectGroups.join('|') || 'all';
const snapshotTreeSelectionBeforeDrag = useCallback(() => {
treeDragSelectionSnapshotRef.current = {
selectedKeys: [...selectedKeys],
@@ -2484,7 +2491,7 @@ const Sidebar: React.FC<{
setV2CommandActiveIndex,
v2ExplorerFilter,
sidebarTableMetadataFields,
treeData,
treeData: visibleSidebarTreeData,
treeViewportWidth,
treeHeight,
isV2Ui,
@@ -3267,7 +3274,7 @@ const Sidebar: React.FC<{
>
<div className="sidebar-tree-scroll-content">
<Tree
key={isV2Ui ? `v2-tree-${v2ExplorerFilter}` : 'legacy-tree'}
key={`${isV2Ui ? `v2-tree-${v2ExplorerFilter}` : 'legacy-tree'}-${sidebarObjectVisibilitySignature}`}
ref={treeRef}
showIcon
draggable={{

View File

@@ -6,6 +6,8 @@ const source = [
readFileSync(new URL('./sidebar/useSidebarTreeLoaders.tsx', import.meta.url), 'utf8'),
].join('\n');
const appSource = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const requiredKeys = [
'sidebar.tree.default_schema',
@@ -19,6 +21,13 @@ const requiredKeys = [
'sidebar.object_group.events',
];
const visibilityKeys = [
'app.settings.sidebar_objects.title',
'app.settings.sidebar_objects.description',
'app.settings.sidebar_objects.action.show_all',
'app.settings.sidebar_objects.action.tables_only',
];
describe('Sidebar object group i18n', () => {
it('localizes database object group titles and default schema fallback', () => {
[
@@ -56,4 +65,16 @@ describe('Sidebar object group i18n', () => {
});
});
});
it('localizes the persistent object visibility settings', () => {
visibilityKeys.forEach((key) => {
expect(appSource).toContain(`t('${key}'`);
});
locales.forEach((locale) => {
const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>;
visibilityKeys.forEach((key) => {
expect(catalog[key], `${locale}:${key}`).toBeTruthy();
});
});
});
});

View File

@@ -75,6 +75,7 @@ describe('store appearance persistence', () => {
expect(appearance.v2CommandSearchPersistentFilterEnabled).toBe(false);
expect(appearance.v2SidebarPersistedFilter).toBe('');
expect(appearance.v2SidebarRailScale).toBe(1);
expect(appearance.sidebarHiddenObjectGroups).toEqual([]);
expect(appearance.showDataTableVerticalBorders).toBe(false);
expect(appearance.showDataTableRowNumber).toBe(true);
expect(appearance.dataTableDensity).toBe('comfortable');
@@ -153,6 +154,33 @@ describe('store appearance persistence', () => {
expect(appearance.v2SidebarRailScale).toBe(1.55);
});
it('persists and sanitizes hidden sidebar object groups', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
sidebarHiddenObjectGroups: ['views', 'routines', 'views'],
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.sidebarHiddenObjectGroups).toEqual(['views', 'routines']);
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().appearance.sidebarHiddenObjectGroups).toEqual(['views', 'routines']);
storage.setItem('lite-db-storage', JSON.stringify({
state: {
appearance: {
sidebarHiddenObjectGroups: ['tables', 'unknown', 'tables', 1],
},
},
version: 16,
}));
vi.resetModules();
const sanitized = await importStore();
expect(sanitized.useStore.getState().appearance.sidebarHiddenObjectGroups).toEqual(['tables']);
});
it('migrates legacy sidebar table comment settings into metadata fields and persists explicit selections', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -115,6 +115,10 @@ import {
sanitizeSidebarTableMetadataFields,
type SidebarTableMetadataField,
} from "./utils/sidebarTableMetadata";
import {
sanitizeSidebarHiddenObjectGroups,
type SidebarObjectGroupKey,
} from "./utils/sidebarObjectVisibility";
export type TableDoubleClickAction = "open-data" | "open-design";
export type ThemeMode = "light" | "dark";
@@ -133,6 +137,7 @@ export interface AppearanceSettings extends DataGridDisplaySettings {
v2CommandSearchPersistentFilterEnabled: boolean;
v2SidebarPersistedFilter: string;
v2SidebarRailScale: number;
sidebarHiddenObjectGroups: SidebarObjectGroupKey[];
customUIFontFamily: string | null;
customMonoFontFamily: string | null;
newQuerySqlTemplate: string | null;
@@ -155,6 +160,7 @@ export const DEFAULT_APPEARANCE: AppearanceSettings = {
v2CommandSearchPersistentFilterEnabled: false,
v2SidebarPersistedFilter: "",
v2SidebarRailScale: DEFAULT_V2_SIDEBAR_RAIL_SCALE,
sidebarHiddenObjectGroups: [],
customUIFontFamily: null,
customMonoFontFamily: null,
newQuerySqlTemplate: null,
@@ -3070,6 +3076,9 @@ const sanitizeAppearance = (
v2SidebarRailScale: sanitizeV2SidebarRailScale(
appearance.v2SidebarRailScale,
),
sidebarHiddenObjectGroups: sanitizeSidebarHiddenObjectGroups(
appearance.sidebarHiddenObjectGroups,
),
customUIFontFamily: sanitizeFontFamilyInput(appearance.customUIFontFamily),
customMonoFontFamily: sanitizeFontFamilyInput(appearance.customMonoFontFamily),
newQuerySqlTemplate: sanitizeNewQuerySqlTemplate(appearance.newQuerySqlTemplate),

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
filterSidebarTreeByHiddenObjectGroups,
sanitizeSidebarHiddenObjectGroups,
} from './sidebarObjectVisibility';
describe('sidebar object visibility', () => {
it('keeps only enabled object categories while preserving the source tree', () => {
const tree = [{
key: 'db',
type: 'database',
children: [
{ key: 'db-queries', type: 'queries-folder', children: [{ key: 'query-1', type: 'saved-query' }] },
{ key: 'db-tables', type: 'object-group', dataRef: { groupKey: 'tables' }, children: [{ key: 'orders', type: 'table' }] },
{ key: 'db-views', type: 'object-group', dataRef: { groupKey: 'views' } },
{
key: 'db-schema-dbo',
type: 'object-group',
dataRef: { groupKey: 'schema' },
children: [
{ key: 'dbo-tables', type: 'object-group', dataRef: { groupKey: 'tables' }, children: [{ key: 'customers', type: 'table' }] },
{ key: 'dbo-events', type: 'object-group', dataRef: { groupKey: 'events' } },
],
},
],
}];
const filtered = filterSidebarTreeByHiddenObjectGroups(tree, [
'savedQueries',
'views',
'events',
]);
expect(filtered[0].children?.map((node) => node.key)).toEqual(['db-tables', 'db-schema-dbo']);
expect(filtered[0].children?.[1].children?.map((node) => node.key)).toEqual(['dbo-tables']);
expect(tree[0].children?.map((node) => node.key)).toEqual([
'db-queries',
'db-tables',
'db-views',
'db-schema-dbo',
]);
});
it('drops invalid and duplicate persisted object group keys', () => {
expect(sanitizeSidebarHiddenObjectGroups([
'views',
'views',
'unknown',
3,
' tables ',
])).toEqual(['views', 'tables']);
});
});

View File

@@ -0,0 +1,76 @@
export const SIDEBAR_OBJECT_GROUP_KEYS = [
'savedQueries',
'tables',
'views',
'materializedViews',
'sequences',
'routines',
'packages',
'triggers',
'events',
] as const;
export type SidebarObjectGroupKey = (typeof SIDEBAR_OBJECT_GROUP_KEYS)[number];
type SidebarObjectVisibilityTreeNode = {
type?: string;
dataRef?: { groupKey?: unknown };
children?: SidebarObjectVisibilityTreeNode[];
isLeaf?: boolean;
};
const SIDEBAR_OBJECT_GROUP_KEY_SET = new Set<string>(SIDEBAR_OBJECT_GROUP_KEYS);
export const sanitizeSidebarHiddenObjectGroups = (value: unknown): SidebarObjectGroupKey[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<SidebarObjectGroupKey>();
value.forEach((item) => {
if (typeof item !== 'string') return;
const key = item.trim();
if (SIDEBAR_OBJECT_GROUP_KEY_SET.has(key)) {
seen.add(key as SidebarObjectGroupKey);
}
});
return Array.from(seen);
};
const resolveObjectGroupKey = (
node: SidebarObjectVisibilityTreeNode,
): SidebarObjectGroupKey | null => {
if (node.type === 'queries-folder' || node.type === 'all-saved-queries') {
return 'savedQueries';
}
if (node.type !== 'object-group') return null;
const groupKey = String(node.dataRef?.groupKey || '').trim();
return SIDEBAR_OBJECT_GROUP_KEY_SET.has(groupKey)
? groupKey as SidebarObjectGroupKey
: null;
};
const isSchemaGroupNode = (node: SidebarObjectVisibilityTreeNode): boolean => (
node.type === 'object-group' && node.dataRef?.groupKey === 'schema'
);
export const filterSidebarTreeByHiddenObjectGroups = <T extends SidebarObjectVisibilityTreeNode>(
nodes: T[],
hiddenObjectGroups: readonly SidebarObjectGroupKey[],
): T[] => {
if (hiddenObjectGroups.length === 0) return nodes;
const hidden = new Set(hiddenObjectGroups);
return nodes.flatMap((node): T[] => {
const objectGroupKey = resolveObjectGroupKey(node);
if (objectGroupKey && hidden.has(objectGroupKey)) return [];
if (!node.children || node.children.length === 0) return [node];
const children = filterSidebarTreeByHiddenObjectGroups(node.children as T[], hiddenObjectGroups);
if (isSchemaGroupNode(node) && children.length === 0) return [];
if (children.length === node.children.length) return [node];
return [{
...node,
children,
...(children.length === 0 ? { isLeaf: true } : {}),
}];
});
};