feat(sidebar): 支持当前表定位到左侧菜单

- 新增左侧工具栏定位按钮,支持按当前激活标签定位表/视图
- 抽离 sidebarLocate 工具函数,统一定位请求解析、路径匹配和 schema 分组
- 侧边栏接收定位事件后自动展开、选中并滚动到目标节点
- 移除 DataGrid 内部定位入口,补充定位与工具栏回归测试
This commit is contained in:
Syngnat
2026-05-09 16:08:03 +08:00
parent ecdbe09c6c
commit 6a0f3f3a73
5 changed files with 674 additions and 28 deletions

View File

@@ -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

View 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);
});
});

View File

@@ -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,

View 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',
]);
});
});

View 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;
};