🐛 fix(sidebar): 恢复连接加载中的转圈状态

- 扩展连接状态为 loading/success/error

- 在连接根节点加载开始时显示 pending,成功后才置绿

- 同步 V2 active host 与 legacy Badge 状态显示并补测试
This commit is contained in:
Syngnat
2026-06-24 22:31:13 +08:00
parent 322d0a7cb8
commit 672d05d124
8 changed files with 63 additions and 13 deletions

View File

@@ -1009,6 +1009,23 @@ describe('Sidebar locate toolbar', () => {
expect(css).toMatch(/\.gn-v2-explorer-filter-tabs button \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/s);
});
it('shows a pending state while a connection root is loading', () => {
const css = readV2ThemeCss();
const source = readSidebarSource();
const treeLoaderSource = readSourceFile('./sidebar/useSidebarTreeLoaders.tsx');
const titleRenderSource = readSourceFile('./sidebar/useSidebarTitleRender.tsx');
const v2ContextMenuSource = readSourceFile('./sidebar/useSidebarV2ContextMenu.tsx');
expect(source).toContain("export type SidebarConnectionState = 'loading' | 'success' | 'error';");
expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));");
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<string, 'loading' | 'live' | 'error' | 'idle'>();");
expect(v2ContextMenuSource).toContain("value === 'loading' ? 'loading'");
expect(css).toMatch(/\.gn-v2-tree-status\.is-loading::before \{[^}]*border: 2px solid rgba\(37, 99, 235, 0\.24\);[^}]*animation: gn-v2-tree-status-spin 0\.8s linear infinite;/s);
expect(css).toMatch(/@keyframes gn-v2-tree-status-spin \{[^}]*to \{ transform: rotate\(360deg\); \}/s);
});
it('keeps v2 tree status dots circular while using virtual horizontal scroll for long labels', () => {
const css = readV2ThemeCss();
const source = readSidebarSource();

View File

@@ -175,6 +175,7 @@ import {
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
type SidebarConnectionState,
type SidebarTreeNode as TreeNode,
type V2CommandSearchItem,
} from './sidebarV2Utils';
@@ -704,8 +705,8 @@ const Sidebar: React.FC<{
return () => window.removeEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true);
}, [closeV2CommandSearch, isV2CommandSearchOpen]);
// Connection Status State: key -> 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
// Connection Status State: key -> 'loading' | 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, SidebarConnectionState>>({});
const [isTreeDragging, setIsTreeDragging] = useState(false);
// Create Database Modal

View File

@@ -9,6 +9,7 @@ import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from
import {
resolveSidebarObjectDragText,
} from '../sidebarCoreUtils';
import type { SidebarConnectionState } from '../sidebarV2Utils';
import {
shouldHideSchemaPrefix,
splitQualifiedName,
@@ -16,7 +17,7 @@ import {
import { resolveV2ObjectGroupTitle } from './sidebarHelpers';
type UseSidebarTitleRenderArgs = {
connectionStates: Record<string, 'success' | 'error'>;
connectionStates: Record<string, SidebarConnectionState>;
isV2Ui: boolean;
renderV2TreeTitle: (node: any, hoverTitle: string, statusBadge: React.ReactNode) => React.ReactNode;
handleAddExternalSQLDirectory: (node: any) => Promise<void>;
@@ -36,16 +37,18 @@ export const useSidebarTitleRender = ({
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
}: UseSidebarTitleRenderArgs) => useCallback((node: any) => {
let status: 'success' | 'error' | 'default' = 'default';
let status: 'loading' | 'success' | 'error' | 'default' = 'default';
if (node.type === 'connection' || node.type === 'database') {
if (connectionStates[node.key] === 'success') status = 'success';
if (connectionStates[node.key] === 'loading') status = 'loading';
else if (connectionStates[node.key] === 'success') status = 'success';
else if (connectionStates[node.key] === 'error') status = 'error';
}
const legacyBadgeStatus = status === 'loading' ? 'processing' : status;
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
isV2Ui
? <span className={`gn-v2-tree-status is-${status}`} aria-hidden="true" />
: <Badge status={status} style={{ marginLeft: 4, marginRight: 8 }} />
: <Badge status={legacyBadgeStatus} style={{ marginLeft: 4, marginRight: 8 }} />
) : null;
const displayTitle = String(node.title ?? '');

View File

@@ -47,6 +47,7 @@ import {
buildSidebarTableChildrenForUi,
isSidebarTablePinned,
sortSidebarTableEntries,
type SidebarConnectionState,
type SidebarTreeNode as TreeNode,
} from '../sidebarV2Utils';
import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities } from '../../../wailsjs/go/app/App';
@@ -126,7 +127,7 @@ type UseSidebarTreeLoadersOptions = {
pinnedSidebarTables: any[];
isV2Ui: boolean;
loadingNodesRef: React.MutableRefObject<Set<string>>;
setConnectionStates: React.Dispatch<React.SetStateAction<Record<string, 'success' | 'error'>>>;
setConnectionStates: React.Dispatch<React.SetStateAction<Record<string, SidebarConnectionState>>>;
setLoadedKeys: React.Dispatch<React.SetStateAction<React.Key[]>>;
replaceTreeNodeChildren: (key: React.Key, children: TreeNode[] | undefined) => TreeNode[];
buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any;
@@ -219,6 +220,7 @@ export const useSidebarTreeLoaders = ({
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));
const config = {
...conn.config,
port: Number(conn.config.port),

View File

@@ -20,6 +20,7 @@ import {
} from '../V2TableContextMenu';
import {
isSidebarTablePinned,
type SidebarConnectionState,
type SidebarTreeNode as TreeNode,
type V2RailConnectionGroup,
} from '../sidebarV2Utils';
@@ -32,7 +33,7 @@ type UseSidebarV2ActionHandlersArgs = {
treeDataRef: MutableRefObject<TreeNode[]>;
findTreeNodeByKeyRef: MutableRefObject<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>;
refreshV2TableContextMenuStatsRef: MutableRefObject<(node: any) => void>;
setConnectionStates: Dispatch<SetStateAction<Record<string, 'success' | 'error'>>>;
setConnectionStates: Dispatch<SetStateAction<Record<string, SidebarConnectionState>>>;
setExpandedKeys: Dispatch<SetStateAction<React.Key[]>>;
setLoadedKeys: Dispatch<SetStateAction<React.Key[]>>;
setTargetConnection: Dispatch<SetStateAction<any>>;

View File

@@ -22,7 +22,7 @@ import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities';
import { resolveConnectionHostSummary } from '../../utils/tabDisplay';
import { resolveConnectionIconType } from '../../utils/connectionVisual';
import { formatSidebarRowCount } from './sidebarHelpers';
import { isSidebarTablePinned, type SidebarTreeNode as TreeNode, type V2RailConnectionGroup } from '../sidebarV2Utils';
import { isSidebarTablePinned, type SidebarConnectionState, type SidebarTreeNode as TreeNode, type V2RailConnectionGroup } from '../sidebarV2Utils';
import { getTableDataDangerActionMeta, supportsTableTruncateAction } from '../tableDataDangerActions';
import {
SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT,
@@ -45,7 +45,7 @@ export type SidebarContextMenuState = {
type SidebarV2ContextMenuOptions = {
connections: SavedConnection[];
connectionStates: Record<string, 'success' | 'error'>;
connectionStates: Record<string, SidebarConnectionState>;
connectionTags: Array<{ id: string; name: string; connectionIds: string[] }>;
activeShortcutPlatform: any;
flattenConnectionNodes: (nodes: TreeNode[]) => TreeNode[];
@@ -104,7 +104,7 @@ export const useSidebarV2ContextMenu = ({
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
const connectionStatusMap = useMemo(() => {
const statusMap = new Map<string, 'live' | 'error' | 'idle'>();
const statusMap = new Map<string, 'loading' | 'live' | 'error' | 'idle'>();
const sortedConnectionIds = connections
.map((conn) => conn.id)
.sort((a, b) => b.length - a.length);
@@ -114,7 +114,7 @@ export const useSidebarV2ContextMenu = ({
Object.entries(connectionStates).forEach(([key, value]) => {
const ownState = statusMap.get(key);
if (ownState !== undefined) {
statusMap.set(key, value === 'success' ? 'live' : 'error');
statusMap.set(key, value === 'loading' ? 'loading' : value === 'success' ? 'live' : 'error');
return;
}
if (value !== 'success') return;
@@ -126,7 +126,7 @@ export const useSidebarV2ContextMenu = ({
return statusMap;
}, [connectionStates, connections]);
const buildRailConnectionStatus = useCallback((connectionId: string): 'live' | 'error' | 'idle' => {
const buildRailConnectionStatus = useCallback((connectionId: string): 'loading' | 'live' | 'error' | 'idle' => {
return connectionStatusMap.get(connectionId) || 'idle';
}, [connectionStatusMap]);

View File

@@ -15,6 +15,8 @@ type SidebarV2Translate = (key: string) => string;
const translateSidebarV2Current: SidebarV2Translate = (key) => t(key);
const translateSidebarV2ZhCN: SidebarV2Translate = (key) => catalogTranslate('zh-CN', key);
export type SidebarConnectionState = 'loading' | 'success' | 'error';
export type SidebarTreeNodeType =
| 'connection'
| 'database'

View File

@@ -2098,6 +2098,15 @@ body[data-ui-version="v2"] .gn-v2-live-dot.is-live {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
}
body[data-ui-version="v2"] .gn-v2-live-dot.is-loading {
width: 10px;
height: 10px;
border: 2px solid rgba(37, 99, 235, 0.24);
border-top-color: #2563eb;
background: transparent;
animation: gn-v2-tree-status-spin 0.8s linear infinite;
}
body[data-ui-version="v2"] .gn-v2-live-dot.is-error {
background: var(--gn-danger);
}
@@ -2760,11 +2769,26 @@ body[data-ui-version="v2"] .gn-v2-tree-status.is-success::before {
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
}
body[data-ui-version="v2"] .gn-v2-tree-status.is-loading::before {
width: 10px;
height: 10px;
flex: 0 0 10px;
border: 2px solid rgba(37, 99, 235, 0.24);
border-top-color: #2563eb;
background: transparent;
box-shadow: none;
animation: gn-v2-tree-status-spin 0.8s linear infinite;
}
body[data-ui-version="v2"] .gn-v2-tree-status.is-error::before {
background: var(--gn-danger);
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.16);
}
@keyframes gn-v2-tree-status-spin {
to { transform: rotate(360deg); }
}
body[data-ui-version="v2"] .gn-v2-tree-label {
min-width: 0;
flex: 1 1 auto;