From e466e87fccbc9ada81fa621e103cd03113ba59b4 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 2 Jul 2026 09:40:09 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=A0=91=E8=8A=82=E7=82=B9=E5=8A=A0=E8=BD=BD=E4=B8=8E?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 51 ++++++++++++++++ frontend/src/components/Sidebar.tsx | 59 +++++++++++++++++++ .../sidebar/useSidebarTreeLoaders.tsx | 25 ++++++-- 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index bd915168..db93494f 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -24,11 +24,13 @@ import Sidebar, { resolveSidebarDropTargetMetricsFromDomEvent, resolveSidebarDropInsertBefore, resolveSidebarNodeConnectionId, + resolveSidebarSwitcherLoadKey, resolveV2ActiveConnectionId, resolveV2ObjectGroupTitle, isSidebarTablePinned, SQLFileExecutionProgressContent, resolveSidebarTableNameForCopy, + shouldKeepSidebarSwitcherCollapsedWhileLoading, shouldClearSidebarActiveContextOnEmptySelect, shouldSkipSidebarLoadOnExpandWhileDragging, shouldSkipSidebarSelectWhileDragging, @@ -319,6 +321,45 @@ describe('Sidebar locate toolbar', () => { expect(shouldLoadSidebarNodeOnExpand({ type: 'object-group', children: [] })).toBe(false); }); + it('keeps sidebar switchers collapsed while lazy loading is still pending', () => { + const connectionNode = { + key: 'conn-1', + data: { + key: 'conn-1', + title: '开发240', + type: 'connection' as const, + dataRef: { id: 'conn-1' }, + }, + expanded: true, + }; + const databaseNode = { + key: 'conn-1-main', + data: { + key: 'conn-1-main', + title: 'main', + type: 'database' as const, + dataRef: { id: 'conn-1', dbName: 'main' }, + }, + expanded: true, + }; + + expect(resolveSidebarSwitcherLoadKey(connectionNode)).toBe('dbs-conn-1'); + expect(resolveSidebarSwitcherLoadKey(databaseNode)).toBe('tables-conn-1-main'); + expect(shouldKeepSidebarSwitcherCollapsedWhileLoading(connectionNode, new Set(['dbs-conn-1']))).toBe(true); + expect(shouldKeepSidebarSwitcherCollapsedWhileLoading(databaseNode, new Set(['tables-conn-1-main']))).toBe(true); + expect(shouldKeepSidebarSwitcherCollapsedWhileLoading({ + key: 'table-users', + data: { + key: 'table-users', + title: 'users', + type: 'table' as const, + dataRef: { id: 'conn-1', dbName: 'main', tableName: 'users' }, + }, + loading: true, + }, new Set())).toBe(true); + expect(shouldKeepSidebarSwitcherCollapsedWhileLoading(databaseNode, new Set())).toBe(false); + }); + it('wires tree expand and double-click expansion to lazy loading', () => { const source = readSidebarSource(); @@ -328,6 +369,9 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('clearTreeNodeChildrenByKeys(keysToClear);'); expect(source).toContain('if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info))'); expect(source).toContain('if (shouldLoadSidebarNodeOnExpand(node))'); + expect(source).toContain('const keepCollapsed = shouldKeepSidebarSwitcherCollapsedWhileLoading(node, loadingNodesRef.current);'); + expect(source).toContain('return ;'); + expect(source).toContain('switcherIcon={renderSidebarSwitcherIcon}'); }); it('uses the appearance preference to switch table double-click to the embedded object designer', () => { @@ -1133,6 +1177,13 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("export type SidebarConnectionState = 'loading' | 'success' | 'error';"); expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));"); expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' }));"); + expect(treeLoaderSource).toContain('let shouldMarkConnectionSuccess = false;'); + expect(treeLoaderSource).toContain('let shouldMarkDatabaseSuccess = false;'); + expect(treeLoaderSource).toContain('loadingNodesRef.current.delete(loadKey);'); + expect(treeLoaderSource).toContain('if (shouldMarkConnectionSuccess) {'); + expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));"); + expect(treeLoaderSource).toContain('if (shouldMarkDatabaseSuccess) {'); + expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));"); expect(titleRenderSource).toContain("let status: 'loading' | 'success' | 'error' | 'default' = 'default';"); expect(titleRenderSource).toContain("if (connectionStates[node.key] === 'loading') status = 'loading';"); expect(v2ContextMenuSource).toContain("const statusMap = new Map();"); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4efa8a55..3f061332 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -68,6 +68,7 @@ import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredVa import { createPortal } from 'react-dom'; import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Popover, Tooltip } from 'antd'; import { + CaretDownFilled, DatabaseOutlined, TableOutlined, ConsoleSqlOutlined, @@ -209,6 +210,55 @@ export { }; export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils'; +type SidebarTreeSwitcherNodeLike = { + key?: React.Key; + data?: TreeNode; + isLeaf?: boolean; + loading?: boolean; +}; + +export const resolveSidebarSwitcherLoadKey = (node: SidebarTreeSwitcherNodeLike | null | undefined): string | null => { + const treeNode = node?.data; + const dataRef = treeNode?.dataRef; + if (!treeNode) { + return null; + } + + if (treeNode.type === 'connection') { + const connectionId = String(dataRef?.id || treeNode.key || node?.key || '').trim(); + return connectionId ? `dbs-${connectionId}` : null; + } + + if (treeNode.type === 'database') { + const connectionId = String(dataRef?.id || '').trim(); + const dbName = String(dataRef?.dbName || '').trim(); + return connectionId && dbName ? `tables-${connectionId}-${dbName}` : null; + } + + if (treeNode.type === 'jvm-mode' || treeNode.type === 'jvm-resource') { + const connectionId = String(dataRef?.id || '').trim(); + const providerMode = String(dataRef?.providerMode || '').trim().toLowerCase(); + const parentPath = treeNode.type === 'jvm-resource' ? String(dataRef?.resourcePath || '').trim() : ''; + return connectionId && providerMode ? `jvm-resources-${connectionId}-${providerMode}-${parentPath}` : null; + } + + return null; +}; + +export const shouldKeepSidebarSwitcherCollapsedWhileLoading = ( + node: SidebarTreeSwitcherNodeLike | null | undefined, + loadingKeys: ReadonlySet, +): boolean => { + if (!node || node.isLeaf) { + return false; + } + if (node.loading) { + return true; + } + const loadKey = resolveSidebarSwitcherLoadKey(node); + return !!loadKey && loadingKeys.has(loadKey); +}; + const { Search } = Input; const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50; const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; @@ -1763,6 +1813,14 @@ const Sidebar: React.FC<{ } } }; + + const renderSidebarSwitcherIcon = useCallback((node: SidebarTreeSwitcherNodeLike) => { + if (node.isLeaf) { + return null; + } + const keepCollapsed = shouldKeepSidebarSwitcherCollapsedWhileLoading(node, loadingNodesRef.current); + return ; + }, []); const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { @@ -2949,6 +3007,7 @@ const Sidebar: React.FC<{ onDoubleClick={onDoubleClick} onSelect={onSelect} titleRender={titleRender} + switcherIcon={renderSidebarSwitcherIcon} expandedKeys={expandedKeys} onExpand={onExpand} loadedKeys={loadedKeys} diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx index bdda3a11..9e535784 100644 --- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -215,12 +215,13 @@ export const useSidebarTreeLoaders = ({ console.warn('检查驱动代理更新状态失败', error); } }; - const loadDatabases = async (node: any) => { + const loadDatabases = async (node: any) => { const conn = node.dataRef as SavedConnection; const loadKey = `dbs-${conn.id}`; if (loadingNodesRef.current.has(loadKey)) return; loadingNodesRef.current.add(loadKey); setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' })); + let shouldMarkConnectionSuccess = false; const config = { ...conn.config, port: Number(conn.config.port), @@ -234,7 +235,6 @@ export const useSidebarTreeLoaders = ({ try { const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any); if (res.success) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : []; const modeNodes: TreeNode[] = capabilities.map((capability) => ({ title: capability.displayLabel || capability.mode, @@ -264,6 +264,7 @@ export const useSidebarTreeLoaders = ({ })); const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]); + shouldMarkConnectionSuccess = true; } else { const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); @@ -300,6 +301,9 @@ export const useSidebarTreeLoaders = ({ } } finally { loadingNodesRef.current.delete(loadKey); + if (shouldMarkConnectionSuccess) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); + } } return; } @@ -309,7 +313,6 @@ export const useSidebarTreeLoaders = ({ try { const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config)); if (res.success) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); const redisRows: any[] = Array.isArray(res.data) ? res.data : []; const redisDbAliases = useStore.getState().appearance.redisDbAliases; let dbs = redisRows.map((db: any) => { @@ -333,6 +336,7 @@ export const useSidebarTreeLoaders = ({ dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex)); } replaceTreeNodeChildren(node.key, dbs); + shouldMarkConnectionSuccess = true; } else { setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); @@ -345,6 +349,9 @@ export const useSidebarTreeLoaders = ({ }); } finally { loadingNodesRef.current.delete(loadKey); + if (shouldMarkConnectionSuccess) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); + } } return; } @@ -352,7 +359,6 @@ export const useSidebarTreeLoaders = ({ try { const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any); if (res.success) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); const dbRows: any[] = Array.isArray(res.data) ? res.data : []; let dbs = dbRows.map((row: any) => ({ title: row.Database || row.database, @@ -375,6 +381,7 @@ export const useSidebarTreeLoaders = ({ setLoadedKeys(prev => prev.filter(k => k !== node.key)); message.warning({ content: t('sidebar.message.no_visible_databases'), key: `conn-${conn.id}-dbs` }); } + shouldMarkConnectionSuccess = true; } else { setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); setLoadedKeys(prev => prev.filter(k => k !== node.key)); @@ -389,6 +396,9 @@ export const useSidebarTreeLoaders = ({ }); } finally { loadingNodesRef.current.delete(loadKey); + if (shouldMarkConnectionSuccess) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); + } } }; @@ -450,6 +460,7 @@ export const useSidebarTreeLoaders = ({ if (loadingNodesRef.current.has(loadKey)) return; loadingNodesRef.current.add(loadKey); setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' })); + let shouldMarkDatabaseSuccess = false; const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName); const queriesNode: TreeNode = { @@ -479,8 +490,6 @@ export const useSidebarTreeLoaders = ({ try { const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName); if (res.success) { - setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); - const tableRows: any[] = Array.isArray(res.data) ? res.data : []; const tableStatusSql = buildSidebarTableStatusSQL(conn as SavedConnection, conn.dbName); const tableStatsResult = tableStatusSql @@ -959,6 +968,7 @@ export const useSidebarTreeLoaders = ({ replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]); } onDatabaseTreeLoaded?.(String(key)); + shouldMarkDatabaseSuccess = true; } else { setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); message.error({ content: res.message, key: `db-${key}-tables` }); @@ -971,6 +981,9 @@ export const useSidebarTreeLoaders = ({ }); } finally { loadingNodesRef.current.delete(loadKey); + if (shouldMarkDatabaseSuccess) { + setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); + } } };