mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 09:29:41 +08:00
✨ feat(sidebar): 支持当前表定位到左侧菜单
- 新增左侧工具栏定位按钮,支持按当前激活标签定位表/视图 - 抽离 sidebarLocate 工具函数,统一定位请求解析、路径匹配和 schema 分组 - 侧边栏接收定位事件后自动展开、选中并滚动到目标节点 - 移除 DataGrid 内部定位入口,补充定位与工具栏回归测试
This commit is contained in:
@@ -103,6 +103,7 @@ describe('DataGrid layout', () => {
|
||||
|
||||
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
|
||||
expect(tableMarkup).toContain('查看 DDL');
|
||||
expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"');
|
||||
|
||||
const schemaTableMarkup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
|
||||
92
frontend/src/components/Sidebar.locate-toolbar.test.tsx
Normal file
92
frontend/src/components/Sidebar.locate-toolbar.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
noop: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
deleteQuery: mocks.noop,
|
||||
saveExternalSQLDirectory: mocks.noop,
|
||||
deleteExternalSQLDirectory: mocks.noop,
|
||||
addConnection: mocks.noop,
|
||||
addTab: mocks.noop,
|
||||
tabs: [{
|
||||
id: 'conn-1-main-users',
|
||||
title: 'users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
}],
|
||||
activeTabId: 'conn-1-main-users',
|
||||
setActiveContext: mocks.noop,
|
||||
removeConnection: mocks.noop,
|
||||
connectionTags: [],
|
||||
addConnectionTag: mocks.noop,
|
||||
updateConnectionTag: mocks.noop,
|
||||
removeConnectionTag: mocks.noop,
|
||||
moveConnectionToTag: mocks.noop,
|
||||
reorderTags: mocks.noop,
|
||||
closeTabsByConnection: mocks.noop,
|
||||
closeTabsByDatabase: mocks.noop,
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
},
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
recordTableAccess: mocks.noop,
|
||||
setTableSortPreference: mocks.noop,
|
||||
addSqlLog: mocks.noop,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
DBGetDatabases: mocks.noop,
|
||||
DBGetTables: mocks.noop,
|
||||
DBQuery: mocks.noop,
|
||||
DBShowCreateTable: mocks.noop,
|
||||
ExportTable: mocks.noop,
|
||||
OpenSQLFile: mocks.noop,
|
||||
ExecuteSQLFile: mocks.noop,
|
||||
CancelSQLFileExecution: mocks.noop,
|
||||
CreateDatabase: mocks.noop,
|
||||
RenameDatabase: mocks.noop,
|
||||
DropDatabase: mocks.noop,
|
||||
RenameTable: mocks.noop,
|
||||
DropTable: mocks.noop,
|
||||
DropView: mocks.noop,
|
||||
DropFunction: mocks.noop,
|
||||
RenameView: mocks.noop,
|
||||
SelectSQLDirectory: mocks.noop,
|
||||
ListSQLDirectory: mocks.noop,
|
||||
ReadSQLFile: mocks.noop,
|
||||
JVMProbeCapabilities: mocks.noop,
|
||||
GetDriverStatusList: mocks.noop,
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/runtime/runtime', () => ({
|
||||
EventsOn: mocks.noop,
|
||||
}));
|
||||
|
||||
describe('Sidebar locate toolbar', () => {
|
||||
it('renders the current table locate action in the sidebar toolbar', () => {
|
||||
const markup = renderToStaticMarkup(<Sidebar />);
|
||||
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');
|
||||
const locateActionIndex = markup.indexOf('data-sidebar-locate-current-tab-action="true"');
|
||||
|
||||
expect(markup).toContain('data-sidebar-locate-current-tab-action="true"');
|
||||
expect(markup).toContain('aria-label="定位当前打开表"');
|
||||
expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
CheckOutlined,
|
||||
FilterOutlined,
|
||||
DashboardOutlined,
|
||||
WarningOutlined
|
||||
WarningOutlined,
|
||||
AimOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
@@ -48,6 +49,14 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||||
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
|
||||
import {
|
||||
findSidebarNodePathByKey,
|
||||
findSidebarNodePathForLocate,
|
||||
normalizeSidebarLocateObjectRequest,
|
||||
normalizeSidebarLocateObjectRequestFromTab,
|
||||
resolveSidebarLocateTarget,
|
||||
type SidebarLocateTreeNodeLike,
|
||||
} from '../utils/sidebarLocate';
|
||||
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
|
||||
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
|
||||
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
|
||||
@@ -175,6 +184,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const removeConnection = useStore(state => state.removeConnection);
|
||||
const connectionTags = useStore(state => state.connectionTags);
|
||||
@@ -198,6 +209,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const disableLocalBackdropFilter = isMacLikePlatform();
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]);
|
||||
const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]);
|
||||
const canLocateActiveTab = !!activeTabLocateRequest;
|
||||
|
||||
// Background Helper (Duplicate logic for now, ideally shared)
|
||||
const getBg = (darkHex: string) => {
|
||||
@@ -257,17 +271,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
|
||||
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
|
||||
// Virtual Scroll State
|
||||
const [treeHeight, setTreeHeight] = useState(500);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const treeRef = useRef<any>(null);
|
||||
const treeDataRef = useRef<TreeNode[]>([]);
|
||||
useEffect(() => {
|
||||
treeDataRef.current = treeData;
|
||||
}, [treeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!treeContainerRef.current) return;
|
||||
@@ -535,6 +554,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return null;
|
||||
};
|
||||
|
||||
const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
|
||||
const nextTreeData = updateTreeData(treeDataRef.current, key, children);
|
||||
treeDataRef.current = nextTreeData;
|
||||
setTreeData(nextTreeData);
|
||||
return nextTreeData;
|
||||
};
|
||||
|
||||
const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => {
|
||||
setExpandedKeys(prev => {
|
||||
const merged = [...prev];
|
||||
requiredKeys.forEach(key => {
|
||||
if (!merged.includes(key)) merged.push(key);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
setAutoExpandParent(true);
|
||||
};
|
||||
|
||||
const scrollSidebarTreeToKey = (key: React.Key) => {
|
||||
const runAfterFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
|
||||
? window.requestAnimationFrame.bind(window)
|
||||
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0);
|
||||
|
||||
runAfterFrame(() => {
|
||||
treeRef.current?.scrollTo?.({ key, align: 'auto' });
|
||||
runAfterFrame(() => {
|
||||
const selectedNode = treeContainerRef.current?.querySelector('.ant-tree-treenode-selected') as HTMLElement | null;
|
||||
selectedNode?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
|
||||
const icon = (() => {
|
||||
switch (node.type) {
|
||||
@@ -1169,12 +1220,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
isLeaf: true,
|
||||
}));
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setTreeData(origin => updateTreeData(origin, node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]));
|
||||
replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]);
|
||||
} else {
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
if (diagnosticNode.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
|
||||
replaceTreeNodeChildren(node.key, diagnosticNode);
|
||||
message.warning({ content: `JVM Provider 探测失败:${res.message || '未知错误'};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
@@ -1185,7 +1236,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
if (diagnosticNode.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
|
||||
replaceTreeNodeChildren(node.key, diagnosticNode);
|
||||
message.warning({ content: `JVM Provider 探测异常:${e?.message || String(e)};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
@@ -1217,7 +1268,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
|
||||
}
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
replaceTreeNodeChildren(node.key, dbs);
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
|
||||
@@ -1251,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
|
||||
if (dbs.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
replaceTreeNodeChildren(node.key, dbs);
|
||||
} else {
|
||||
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
@@ -1305,7 +1356,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
},
|
||||
isLeaf: item.hasChildren !== true,
|
||||
}));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, resourceNodes));
|
||||
replaceTreeNodeChildren(node.key, resourceNodes);
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
|
||||
@@ -1616,7 +1667,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
|
||||
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...schemaNodes]);
|
||||
} else {
|
||||
const groupedNodes: TreeNode[] = [
|
||||
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
|
||||
@@ -1625,7 +1676,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
|
||||
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...groupedNodes]);
|
||||
}
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
@@ -1639,6 +1690,102 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
|
||||
|
||||
const waitForSidebarLoadKey = async (loadKey: string) => {
|
||||
for (let attempt = 0; attempt < 30 && loadingNodesRef.current.has(loadKey); attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 50));
|
||||
}
|
||||
};
|
||||
|
||||
const locateObjectInSidebar = async (detail: unknown) => {
|
||||
const request = normalizeSidebarLocateObjectRequest(detail);
|
||||
if (!request) {
|
||||
message.warning('当前标签页没有可定位的表上下文');
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = connections.find(item => item.id === request.connectionId);
|
||||
if (!conn) {
|
||||
message.warning('未找到当前表对应的连接');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = resolveSidebarLocateTarget(request, {
|
||||
groupBySchema: shouldHideSchemaPrefix(conn),
|
||||
});
|
||||
const objectLabel = request.objectGroup === 'views' ? '视图' : '表';
|
||||
|
||||
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||||
const dbLoadKey = `dbs-${request.connectionId}`;
|
||||
const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`;
|
||||
|
||||
if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) {
|
||||
const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey);
|
||||
if (!connectionNode) {
|
||||
message.warning('未在左侧树找到当前连接');
|
||||
return;
|
||||
}
|
||||
if (loadingNodesRef.current.has(dbLoadKey)) {
|
||||
await waitForSidebarLoadKey(dbLoadKey);
|
||||
} else {
|
||||
await loadDatabases(connectionNode);
|
||||
}
|
||||
}
|
||||
|
||||
const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey);
|
||||
if (!dbNode) {
|
||||
message.warning(`未在左侧树找到数据库:${request.dbName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||||
if (!path) {
|
||||
if (loadingNodesRef.current.has(tableLoadKey)) {
|
||||
await waitForSidebarLoadKey(tableLoadKey);
|
||||
} else {
|
||||
await loadTables(dbNode);
|
||||
}
|
||||
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
message.warning(`${objectLabel}未在左侧树中找到:${request.tableName},请刷新数据库节点后重试`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKey = path[path.length - 1];
|
||||
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
|
||||
setSearchValue('');
|
||||
mergeExpandedTreeKeys(path.slice(0, -1));
|
||||
setSelectedKeys([targetKey]);
|
||||
selectedNodesRef.current = targetNode ? [targetNode] : [];
|
||||
setActiveContext({ connectionId: request.connectionId, dbName: request.dbName });
|
||||
scrollSidebarTreeToKey(targetKey);
|
||||
};
|
||||
|
||||
const handleLocateActiveTabInSidebar = () => {
|
||||
if (!activeTabLocateRequest) {
|
||||
message.warning('当前标签页没有可定位的表上下文');
|
||||
return;
|
||||
}
|
||||
void locateObjectInSidebar(activeTabLocateRequest);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
locateObjectInSidebarRef.current = locateObjectInSidebar;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleLocateSidebarObject = (event: Event) => {
|
||||
void locateObjectInSidebarRef.current((event as CustomEvent).detail);
|
||||
};
|
||||
window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||||
if (type === 'tag') return;
|
||||
if (children) return;
|
||||
@@ -1688,7 +1835,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
];
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, folders));
|
||||
replaceTreeNodeChildren(key, folders);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3708,7 +3855,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
replaceTreeNodeChildren(node.key, undefined);
|
||||
closeTabsByConnection(String(node.key));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
@@ -3858,7 +4005,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
// Reset loaded state recursively
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
// Clear children (undefined to trigger reload)
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
replaceTreeNodeChildren(node.key, undefined);
|
||||
closeTabsByConnection(String(node.key));
|
||||
message.success("已断开连接");
|
||||
}
|
||||
@@ -4006,7 +4153,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
replaceTreeNodeChildren(node.key, undefined);
|
||||
if (dbConnId && dbName) {
|
||||
closeTabsByDatabase(dbConnId, dbName);
|
||||
}
|
||||
@@ -4255,13 +4402,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onOk: () => {
|
||||
deleteQuery(q.id);
|
||||
// 从树中移除节点
|
||||
setTreeData(origin => {
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
.filter(n => n.key !== node.key)
|
||||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||||
return removeNode(origin);
|
||||
});
|
||||
const removeNode = (list: TreeNode[]): TreeNode[] =>
|
||||
list
|
||||
.filter(n => n.key !== node.key)
|
||||
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
|
||||
const nextTreeData = removeNode(treeDataRef.current);
|
||||
treeDataRef.current = nextTreeData;
|
||||
setTreeData(nextTreeData);
|
||||
message.success('查询已删除');
|
||||
}
|
||||
});
|
||||
@@ -4552,13 +4699,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<Button size="small" type="text" icon={<DatabaseOutlined />} onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="运行外部SQL文件">
|
||||
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<FileAddOutlined />}
|
||||
data-sidebar-open-external-sql-file-action="true"
|
||||
onClick={handleOpenSQLFileFromToolbar}
|
||||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<AimOutlined />}
|
||||
aria-label="定位当前打开表"
|
||||
data-sidebar-locate-current-tab-action="true"
|
||||
disabled={!canLocateActiveTab}
|
||||
onClick={handleLocateActiveTabInSidebar}
|
||||
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div ref={treeContainerRef} className="sidebar-tree-scroll-shell" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className="sidebar-tree-scroll-content">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
showIcon
|
||||
draggable={{
|
||||
icon: false,
|
||||
|
||||
163
frontend/src/utils/sidebarLocate.test.ts
Normal file
163
frontend/src/utils/sidebarLocate.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findSidebarNodePathByKey,
|
||||
findSidebarNodePathForLocate,
|
||||
normalizeSidebarLocateObjectRequest,
|
||||
normalizeSidebarLocateObjectRequestFromTab,
|
||||
resolveSidebarLocateTarget,
|
||||
} from './sidebarLocate';
|
||||
|
||||
describe('sidebarLocate', () => {
|
||||
it('normalizes a table locate request and builds the direct tree path', () => {
|
||||
const request = normalizeSidebarLocateObjectRequest({
|
||||
tabId: 'conn-1-main-users',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
tabId: 'conn-1-main-users',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
schemaName: '',
|
||||
objectGroup: 'tables',
|
||||
});
|
||||
|
||||
expect(resolveSidebarLocateTarget(request!, { groupBySchema: false })).toMatchObject({
|
||||
targetKey: 'conn-1-main-users',
|
||||
expectedAncestorKeys: ['conn-1', 'conn-1-main', 'conn-1-main-tables'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps view tabs on the views branch and includes schema ancestors', () => {
|
||||
const request = normalizeSidebarLocateObjectRequest({
|
||||
tabId: 'conn-1-main-view-public.orders_view',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.orders_view',
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
objectGroup: 'views',
|
||||
schemaName: 'public',
|
||||
});
|
||||
|
||||
expect(resolveSidebarLocateTarget(request!, { groupBySchema: true })).toMatchObject({
|
||||
targetKey: 'conn-1-main-view-public.orders_view',
|
||||
schemaKey: 'conn-1-main-schema-public',
|
||||
objectGroupKey: 'conn-1-main-schema-public-views',
|
||||
expectedAncestorKeys: [
|
||||
'conn-1',
|
||||
'conn-1-main',
|
||||
'conn-1-main-schema-public',
|
||||
'conn-1-main-schema-public-views',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a locate request from the active table tab', () => {
|
||||
expect(normalizeSidebarLocateObjectRequestFromTab({
|
||||
id: 'conn-1-main-public.users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
})).toMatchObject({
|
||||
tabId: 'conn-1-main-public.users',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
schemaName: 'public',
|
||||
objectGroup: 'tables',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a view locate request from view tabs and rejects non-object tabs', () => {
|
||||
expect(normalizeSidebarLocateObjectRequestFromTab({
|
||||
id: 'view-def-conn-1-main-public.orders_view',
|
||||
type: 'view-def',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
viewName: 'public.orders_view',
|
||||
})).toMatchObject({
|
||||
tableName: 'public.orders_view',
|
||||
schemaName: 'public',
|
||||
objectGroup: 'views',
|
||||
});
|
||||
|
||||
expect(normalizeSidebarLocateObjectRequestFromTab({
|
||||
id: 'query-1',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('finds a locate path from loaded tree data even when the target key is absent', () => {
|
||||
const target = resolveSidebarLocateTarget(
|
||||
{
|
||||
tabId: 'stale-tab-id',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
schemaName: 'public',
|
||||
objectGroup: 'tables',
|
||||
},
|
||||
{ groupBySchema: true },
|
||||
);
|
||||
|
||||
const tree = [
|
||||
{
|
||||
key: 'conn-1',
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main',
|
||||
dataRef: { id: 'conn-1', dbName: 'main' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main-schema-public',
|
||||
dataRef: { id: 'conn-1', dbName: 'main', schemaName: 'public' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main-schema-public-tables',
|
||||
dataRef: { id: 'conn-1', dbName: 'main', groupKey: 'tables', schemaName: 'public' },
|
||||
children: [
|
||||
{
|
||||
key: 'conn-1-main-public.users',
|
||||
type: 'table',
|
||||
dataRef: {
|
||||
id: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'public.users',
|
||||
schemaName: 'public',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findSidebarNodePathByKey(tree, 'conn-1-main-public.users')).toEqual([
|
||||
'conn-1',
|
||||
'conn-1-main',
|
||||
'conn-1-main-schema-public',
|
||||
'conn-1-main-schema-public-tables',
|
||||
'conn-1-main-public.users',
|
||||
]);
|
||||
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
|
||||
'conn-1',
|
||||
'conn-1-main',
|
||||
'conn-1-main-schema-public',
|
||||
'conn-1-main-schema-public-tables',
|
||||
'conn-1-main-public.users',
|
||||
]);
|
||||
});
|
||||
});
|
||||
221
frontend/src/utils/sidebarLocate.ts
Normal file
221
frontend/src/utils/sidebarLocate.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
export type SidebarLocateObjectGroup = 'tables' | 'views';
|
||||
|
||||
export interface SidebarLocateObjectRequest {
|
||||
tabId?: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
schemaName?: string;
|
||||
objectGroup: SidebarLocateObjectGroup;
|
||||
}
|
||||
|
||||
export interface SidebarLocateTarget {
|
||||
connectionKey: string;
|
||||
databaseKey: string;
|
||||
targetKey: string;
|
||||
objectGroup: SidebarLocateObjectGroup;
|
||||
objectGroupKey: string;
|
||||
schemaKey?: string;
|
||||
expectedAncestorKeys: string[];
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
tableName: string;
|
||||
schemaName: string;
|
||||
}
|
||||
|
||||
export interface SidebarLocateTreeNodeLike {
|
||||
key: string | number;
|
||||
type?: string;
|
||||
dataRef?: Record<string, any>;
|
||||
children?: SidebarLocateTreeNodeLike[];
|
||||
}
|
||||
|
||||
export interface SidebarLocateTabLike {
|
||||
id?: string;
|
||||
type?: string;
|
||||
connectionId?: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
viewName?: string;
|
||||
}
|
||||
|
||||
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
|
||||
|
||||
export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
|
||||
const raw = toTrimmedString(qualifiedName);
|
||||
if (!raw) return { schemaName: '', objectName: '' };
|
||||
const idx = raw.lastIndexOf('.');
|
||||
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
|
||||
return {
|
||||
schemaName: raw.substring(0, idx).trim(),
|
||||
objectName: raw.substring(idx + 1).trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string, dbName: string): SidebarLocateObjectGroup => {
|
||||
const explicitGroup = toTrimmedString(detail.objectGroup);
|
||||
if (explicitGroup === 'views' || explicitGroup === 'view') return 'views';
|
||||
|
||||
const explicitType = toTrimmedString(detail.objectType);
|
||||
if (explicitType === 'view' || explicitType === 'views') return 'views';
|
||||
|
||||
const tabId = toTrimmedString(detail.tabId);
|
||||
const dbNodeKey = `${connectionId}-${dbName}`;
|
||||
if (tabId.startsWith(`${dbNodeKey}-view-`)) return 'views';
|
||||
|
||||
return 'tables';
|
||||
};
|
||||
|
||||
export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLocateObjectRequest | null => {
|
||||
const raw = (detail || {}) as Record<string, unknown>;
|
||||
const connectionId = toTrimmedString(raw.connectionId);
|
||||
const dbName = toTrimmedString(raw.dbName);
|
||||
const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName);
|
||||
|
||||
if (!connectionId || !dbName || !tableName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = splitSidebarQualifiedName(tableName);
|
||||
const schemaName = toTrimmedString(raw.schemaName) || parsed.schemaName;
|
||||
|
||||
return {
|
||||
tabId: toTrimmedString(raw.tabId) || undefined,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
schemaName,
|
||||
objectGroup: inferObjectGroup(raw, connectionId, dbName),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTabLike | null | undefined): SidebarLocateObjectRequest | null => {
|
||||
if (!tab) return null;
|
||||
const objectName = tab.type === 'view-def'
|
||||
? toTrimmedString(tab.viewName || tab.tableName)
|
||||
: toTrimmedString(tab.tableName || tab.viewName);
|
||||
if (tab.type !== 'table' && tab.type !== 'view-def') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSidebarLocateObjectRequest({
|
||||
tabId: tab.id,
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
tableName: objectName,
|
||||
objectGroup: tab.type === 'view-def' ? 'views' : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveSidebarLocateTarget = (
|
||||
request: SidebarLocateObjectRequest,
|
||||
options: { groupBySchema: boolean },
|
||||
): SidebarLocateTarget => {
|
||||
const connectionKey = request.connectionId;
|
||||
const databaseKey = `${request.connectionId}-${request.dbName}`;
|
||||
const fallbackTargetKey = request.objectGroup === 'views'
|
||||
? `${databaseKey}-view-${request.tableName}`
|
||||
: `${databaseKey}-${request.tableName}`;
|
||||
const targetKey = request.tabId || fallbackTargetKey;
|
||||
const schemaSegment = request.schemaName || 'default';
|
||||
const schemaKey = options.groupBySchema ? `${databaseKey}-schema-${schemaSegment}` : undefined;
|
||||
const objectGroupKey = options.groupBySchema
|
||||
? `${schemaKey}-${request.objectGroup}`
|
||||
: `${databaseKey}-${request.objectGroup}`;
|
||||
const expectedAncestorKeys = [
|
||||
connectionKey,
|
||||
databaseKey,
|
||||
...(schemaKey ? [schemaKey] : []),
|
||||
objectGroupKey,
|
||||
];
|
||||
|
||||
return {
|
||||
connectionKey,
|
||||
databaseKey,
|
||||
targetKey,
|
||||
objectGroup: request.objectGroup,
|
||||
objectGroupKey,
|
||||
schemaKey,
|
||||
expectedAncestorKeys,
|
||||
connectionId: request.connectionId,
|
||||
dbName: request.dbName,
|
||||
tableName: request.tableName,
|
||||
schemaName: request.schemaName || '',
|
||||
};
|
||||
};
|
||||
|
||||
export const findSidebarNodePathByKey = (
|
||||
nodes: SidebarLocateTreeNodeLike[],
|
||||
targetKey: string,
|
||||
): string[] | null => {
|
||||
for (const node of nodes) {
|
||||
const nodeKey = String(node.key);
|
||||
if (nodeKey === targetKey) {
|
||||
return [nodeKey];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const childPath = findSidebarNodePathByKey(node.children, targetKey);
|
||||
if (childPath) {
|
||||
return [nodeKey, ...childPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string): boolean => {
|
||||
const normalizedNodeName = toTrimmedString(nodeObjectName);
|
||||
if (!normalizedNodeName) return false;
|
||||
if (normalizedNodeName === target.tableName) return true;
|
||||
|
||||
if (!target.schemaName) return false;
|
||||
|
||||
const nodeParsed = splitSidebarQualifiedName(normalizedNodeName);
|
||||
const targetParsed = splitSidebarQualifiedName(target.tableName);
|
||||
const nodeObject = nodeParsed.objectName || normalizedNodeName;
|
||||
const targetObject = targetParsed.objectName || target.tableName;
|
||||
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
|
||||
return resolvedNodeSchema === target.schemaName && nodeObject === targetObject;
|
||||
};
|
||||
|
||||
const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => {
|
||||
const dataRef = node.dataRef || {};
|
||||
const nodeConnectionId = toTrimmedString(dataRef.id || dataRef.connectionId);
|
||||
const nodeDbName = toTrimmedString(dataRef.dbName);
|
||||
|
||||
if (nodeConnectionId !== target.connectionId || nodeDbName !== target.dbName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.objectGroup === 'views') {
|
||||
if (node.type !== 'view') return false;
|
||||
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
|
||||
}
|
||||
|
||||
if (node.type !== 'table') return false;
|
||||
return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName));
|
||||
};
|
||||
|
||||
export const findSidebarNodePathForLocate = (
|
||||
nodes: SidebarLocateTreeNodeLike[],
|
||||
target: SidebarLocateTarget,
|
||||
): string[] | null => {
|
||||
const exactPath = findSidebarNodePathByKey(nodes, target.targetKey);
|
||||
if (exactPath) return exactPath;
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeKey = String(node.key);
|
||||
if (matchesLocateObjectNode(node, target)) {
|
||||
return [nodeKey];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const childPath = findSidebarNodePathForLocate(node.children, target);
|
||||
if (childPath) {
|
||||
return [nodeKey, ...childPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user