🐛 fix(workbench): 最近连接返回资源树

- 点击最近连接改为定位并选中侧栏连接或数据库
- 等待连接数据库加载完成后再恢复目标数据库选择
- 保留 Redis、Nacos 等非查询数据源的最近连接入口
- 外部 SQL 目录仍按原行为创建查询标签页
- 增加最近连接与侧栏定位事件回归测试
This commit is contained in:
Syngnat
2026-08-07 15:21:26 +08:00
parent c3d3f89561
commit fdb73e1f83
6 changed files with 143 additions and 19 deletions

View File

@@ -155,12 +155,14 @@ import {
resolveSidebarRuntimeDatabase,
} from '../utils/sidebarMetadata';
import {
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
type SidebarLocateTreeNodeLike,
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
SIDEBAR_LOCATE_CONNECTION_EVENT,
normalizeSidebarLocateConnectionRequest,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
type SidebarLocateTreeNodeLike,
} from '../utils/sidebarLocate';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import {
@@ -2990,10 +2992,53 @@ const Sidebar: React.FC<{
expandConnectionFromRailRef.current = (connectionId: string) => {
const conn = connections.find((item) => item.id === connectionId);
if (conn) {
selectConnectionFromRail(conn);
void selectConnectionFromRail(conn);
}
};
const locateConnectionInSidebar = useCallback(async (detail: unknown) => {
const request = normalizeSidebarLocateConnectionRequest(detail);
if (!request) return;
const connection = connections.find((item) => item.id === request.connectionId);
if (!connection) return;
onExpandSidebar?.();
setSearchValue('');
await selectConnectionFromRail(connection);
if (!request.dbName) {
scrollSidebarTreeToKey(connection.id);
return;
}
await waitForSidebarLoadKey(`dbs-${connection.id}`);
const databaseNode = findTreeNodeByKeyRef.current(
treeDataRef.current,
`${connection.id}-${request.dbName}`,
);
if (!databaseNode) {
scrollSidebarTreeToKey(connection.id);
return;
}
const dbName = String(databaseNode.dataRef?.dbName || request.dbName).trim();
setSelectedKeys([databaseNode.key]);
selectedNodesRef.current = [databaseNode];
setActiveContext({ connectionId: connection.id, dbName });
scrollSidebarTreeToKey(databaseNode.key);
}, [connections, findTreeNodeByKeyRef, onExpandSidebar, scrollSidebarTreeToKey, selectConnectionFromRail, selectedNodesRef, setActiveContext, setSearchValue, setSelectedKeys, treeDataRef]);
useEffect(() => {
const handleLocateSidebarConnection = (event: Event) => {
void locateConnectionInSidebar((event as CustomEvent).detail);
};
window.addEventListener(SIDEBAR_LOCATE_CONNECTION_EVENT, handleLocateSidebarConnection as EventListener);
return () => {
window.removeEventListener(SIDEBAR_LOCATE_CONNECTION_EVENT, handleLocateSidebarConnection as EventListener);
};
}, [locateConnectionInSidebar]);
const getNodeMenuItems = (node: any): MenuProps['items'] => buildSidebarLegacyNodeMenuItems(node, {
addTab,
getMetadataDialect,

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
buildPinnedTableShortcuts,
buildRecentConnectionShortcuts,
buildRecentSQLFileShortcuts,
dispatchRecentConnectionShortcut,
RecentConnectionShortcutItem,
} from './TabManager';
import type { ExternalSQLDirectory, SavedConnection } from '../types';
@@ -55,7 +56,7 @@ describe('recent workbench shortcuts', () => {
expect(customizedMarkup).not.toContain('anticon-database');
});
it('only offers connections that can open the SQL query editor', () => {
it('keeps recent connections even when they do not support the SQL query editor', () => {
const shortcuts = buildRecentConnectionShortcuts([
connection('redis-1', 'redis'),
connection('mysql-1', 'mysql'),
@@ -65,6 +66,10 @@ describe('recent workbench shortcuts', () => {
]);
expect(shortcuts).toEqual([
expect.objectContaining({
connection: expect.objectContaining({ id: 'redis-1' }),
dbName: '0',
}),
expect.objectContaining({
connection: expect.objectContaining({ id: 'mysql-1' }),
dbName: 'orders',
@@ -72,6 +77,20 @@ describe('recent workbench shortcuts', () => {
]);
});
it('dispatches a sidebar navigation request instead of creating a query tab', () => {
const eventTarget = { dispatchEvent: vi.fn() };
const mysqlConnection = connection('mysql-1', 'mysql');
expect(dispatchRecentConnectionShortcut({
connection: mysqlConnection,
dbName: 'orders',
}, eventTarget)).toBe(true);
const event = eventTarget.dispatchEvent.mock.calls[0]?.[0] as CustomEvent;
expect(event.type).toBe('gonavi:locate-sidebar-connection');
expect(event.detail).toEqual({ connectionId: 'mysql-1', dbName: 'orders' });
});
it('only exposes valid pinned tables whose connection still exists', () => {
const shortcuts = buildPinnedTableShortcuts([
connection('mysql-1', 'mysql'),

View File

@@ -56,6 +56,7 @@ import { createSidebarResizeAwareFrameScheduler } from '../utils/sidebarResizeLi
import { QUERY_TAB_RENAME_REQUEST_EVENT } from '../utils/queryTabTitle';
import { getDbIcon } from './DatabaseIcons';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import { dispatchSidebarLocateConnection } from '../utils/sidebarLocate';
const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'query') return t('tab_manager.kind_badge.query');
@@ -124,6 +125,14 @@ export type RecentConnectionShortcut = {
dbName?: string;
};
export const dispatchRecentConnectionShortcut = (
shortcut: Pick<RecentConnectionShortcut, 'connection' | 'dbName'>,
eventTarget?: Pick<Window, 'dispatchEvent'> | null,
): boolean => dispatchSidebarLocateConnection({
connectionId: shortcut.connection.id,
...(shortcut.dbName ? { dbName: shortcut.dbName } : {}),
}, eventTarget);
export type PinnedTableShortcut = {
connection: SavedConnection;
dbName: string;
@@ -143,10 +152,7 @@ export const buildRecentConnectionShortcuts = (
connections: SavedConnection[],
recentTargets: RecentConnectionTarget[],
): RecentConnectionShortcut[] => {
const queryCapableConnections = connections.filter((connection) =>
getDataSourceCapabilities(connection.config).supportsQueryEditor,
);
const connectionById = new Map(queryCapableConnections.map((connection) => [connection.id, connection]));
const connectionById = new Map(connections.map((connection) => [connection.id, connection]));
const seen = new Set<string>();
const seenConnectionIds = new Set<string>();
const result: RecentConnectionShortcut[] = [];
@@ -166,7 +172,7 @@ export const buildRecentConnectionShortcuts = (
append(connection, target.dbName);
}
});
queryCapableConnections.forEach((connection) => {
connections.forEach((connection) => {
if (!seenConnectionIds.has(connection.id)) {
append(connection);
}
@@ -1461,6 +1467,10 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
};
const handleOpenRecentConnection = useCallback((shortcut: RecentConnectionShortcut) => {
dispatchRecentConnectionShortcut(shortcut);
}, []);
const handleCreateQueryForConnection = useCallback((shortcut: Pick<RecentConnectionShortcut, 'connection' | 'dbName'>) => {
addTab({
id: buildWorkbenchQueryTabId(),
title: t('query.new'),
@@ -1722,7 +1732,7 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
key={shortcut.directory.id}
type="button"
className="gn-v2-empty-recent-item"
onClick={() => handleOpenRecentConnection(shortcut)}
onClick={() => handleCreateQueryForConnection(shortcut)}
>
<FolderOpenOutlined />
<span>

View File

@@ -45,7 +45,7 @@ export const useSidebarCommandSearchRunner = ({
treeDataRef,
v2CommandActiveIndex,
}: UseSidebarCommandSearchRunnerArgs) => {
const selectConnectionFromRail = useCallback((conn: SavedConnection) => {
const selectConnectionFromRail = useCallback((conn: SavedConnection): Promise<void> => {
const key = conn.id;
const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, key);
setSelectedKeys([key]);
@@ -57,7 +57,7 @@ export const useSidebarCommandSearchRunner = ({
dataRef: conn,
type: 'connection',
};
void loadDatabases(targetNode);
return loadDatabases(targetNode);
}, [findTreeNodeByKeyRef, loadDatabases, mergeExpandedTreeKeys, selectedNodesRef, setActiveContext, setSelectedKeys, treeDataRef]);
const runCommandSearchItem = useCallback((item?: V2CommandSearchItem) => {
@@ -82,7 +82,7 @@ export const useSidebarCommandSearchRunner = ({
const node = item.node;
const dataRef = node.dataRef || {};
if (node.type === 'connection') {
selectConnectionFromRail(dataRef as SavedConnection);
void selectConnectionFromRail(dataRef as SavedConnection);
return;
}
if (node.type === 'database') {

View File

@@ -1,14 +1,33 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
dispatchSidebarLocateConnection,
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
normalizeSidebarLocateConnectionRequest,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
} from './sidebarLocate';
describe('sidebarLocate', () => {
it('normalizes and dispatches a connection navigation request', () => {
expect(normalizeSidebarLocateConnectionRequest({
connectionId: ' conn-1 ',
dbName: ' orders ',
})).toEqual({ connectionId: 'conn-1', dbName: 'orders' });
expect(normalizeSidebarLocateConnectionRequest({ connectionId: ' ' })).toBeNull();
const eventTarget = { dispatchEvent: vi.fn() };
expect(dispatchSidebarLocateConnection({
connectionId: 'conn-1',
dbName: 'orders',
}, eventTarget)).toBe(true);
const event = eventTarget.dispatchEvent.mock.calls[0]?.[0] as CustomEvent;
expect(event.type).toBe('gonavi:locate-sidebar-connection');
expect(event.detail).toEqual({ connectionId: 'conn-1', dbName: 'orders' });
});
it('normalizes a table locate request and builds the direct tree path', () => {
const request = normalizeSidebarLocateObjectRequest({
tabId: 'conn-1-main-users',

View File

@@ -1,8 +1,15 @@
import { splitQualifiedNameLast } from './qualifiedName';
export const SIDEBAR_LOCATE_CONNECTION_EVENT = 'gonavi:locate-sidebar-connection';
export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines' | 'sequences' | 'packages' | 'externalSqlFiles';
export type SidebarLocateDatabaseObjectGroup = Exclude<SidebarLocateObjectGroup, 'externalSqlFiles'>;
export interface SidebarLocateConnectionRequest {
connectionId: string;
dbName?: string;
}
export interface SidebarLocateDatabaseObjectRequest {
tabId?: string;
connectionId: string;
@@ -70,6 +77,30 @@ const normalizeLocateName = (value: string): string => toTrimmedString(value).to
const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedString(value).replace(/\\/g, '/');
export const normalizeSidebarLocateConnectionRequest = (detail: unknown): SidebarLocateConnectionRequest | null => {
const raw = (detail || {}) as Record<string, unknown>;
const connectionId = toTrimmedString(raw.connectionId);
if (!connectionId) return null;
const dbName = toTrimmedString(raw.dbName);
return {
connectionId,
...(dbName ? { dbName } : {}),
};
};
export const dispatchSidebarLocateConnection = (
detail: unknown,
eventTarget?: Pick<Window, 'dispatchEvent'> | null,
): boolean => {
const request = normalizeSidebarLocateConnectionRequest(detail);
const target = eventTarget ?? (typeof window === 'undefined' ? null : window);
if (!request || !target || typeof CustomEvent !== 'function') return false;
target.dispatchEvent(new CustomEvent<SidebarLocateConnectionRequest>(SIDEBAR_LOCATE_CONNECTION_EVENT, {
detail: request,
}));
return true;
};
export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = toTrimmedString(qualifiedName);
if (!raw) return { schemaName: '', objectName: '' };