♻️ refactor(sidebar): 拆分 V2 侧栏工具逻辑

This commit is contained in:
Syngnat
2026-06-09 13:39:11 +08:00
parent 8543f1dc65
commit 77b58baff7
3 changed files with 840 additions and 719 deletions

View File

@@ -702,13 +702,14 @@ describe('Sidebar locate toolbar', () => {
it('keeps v2 tree status dots circular while using virtual horizontal scroll for long labels', () => {
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8');
expect(source).toContain('gn-v2-tree-status is-${status}');
expect(source).toContain('data-sidebar-tree-folder-icon="true"');
expect(source).toContain("overflow: 'hidden'");
expect(source).not.toContain("overflowX: isV2Ui ? 'auto' : 'hidden'");
expect(source).toContain('scrollWidth={isV2Ui ? v2TreeHorizontalScrollWidth : undefined}');
expect(source).toContain('const V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE = 32;');
expect(utilsSource).toContain('export const V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE = 32;');
expect(source).toContain('const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth');
expect(source).toContain('treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE');
expect(source).toContain('height={effectiveTreeHeight}');
@@ -869,6 +870,7 @@ describe('Sidebar locate toolbar', () => {
it('reorders dragged connections instead of only moving them between groups', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8');
expect(source).toContain('const reorderConnections = useStore(state => state.reorderConnections);');
expect(source).toContain('reorderConnections(');
@@ -876,7 +878,7 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain('const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);');
expect(source).toContain('const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);');
expect(source).toContain("findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key)");
expect(source).toContain("const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;");
expect(utilsSource).toContain("const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;");
expect(source).toContain('insertBefore,');
});

View File

@@ -48,12 +48,11 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
import {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
buildSidebarTablePinKey,
resolveSidebarRootOrderTokens,
useStore,
} from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ConnectionTag, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
@@ -98,6 +97,77 @@ import {
type V2TableContextMenuStats,
type V2TableGroupContextMenuActionKey,
} from './V2TableContextMenu';
import {
V2_EXPLORER_FILTER_OPTIONS,
V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE,
buildSidebarTableChildrenForUi,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
formatSidebarRowCount,
getV2RailConnectionGroupBadgeText,
hasSidebarLazyChildren,
isSidebarTablePinned,
normalizeSidebarTreeRelativeDropPosition,
parseV2CommandSearchQuery,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarNodeConnectionId,
resolveSidebarTableNameForCopy,
resolveSidebarTagDropInsertBefore,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldClearSidebarActiveContextOnEmptySelect,
shouldCloseV2CommandSearchOnGlobalKey,
shouldLoadSidebarNodeOnExpand,
shouldRunV2CommandSearchEnter,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
sortSidebarTableEntries,
type V2CommandSearchItem,
type V2ExplorerFilter,
type V2RailConnectionGroup,
} from './sidebarV2Utils';
export {
buildSidebarTableChildrenForUi,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
formatSidebarRowCount,
getV2RailConnectionGroupBadgeText,
hasSidebarLazyChildren,
isSidebarTablePinned,
normalizeSidebarTreeRelativeDropPosition,
parseV2CommandSearchQuery,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarNodeConnectionId,
resolveSidebarTableNameForCopy,
resolveSidebarTagDropInsertBefore,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldClearSidebarActiveContextOnEmptySelect,
shouldCloseV2CommandSearchOnGlobalKey,
shouldLoadSidebarNodeOnExpand,
shouldRunV2CommandSearchEnter,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
sortSidebarTableEntries,
};
export type {
V2CommandSearchItem,
V2ExplorerFilter,
V2RailConnectionGroup,
} from './sidebarV2Utils';
const { Search } = Input;
type SidebarContextMenuState = {
@@ -177,345 +247,11 @@ const resolveSidebarObjectDragText = (node: Pick<TreeNode, 'type' | 'title' | 'd
return '';
};
export const hasSidebarLazyChildren = (children: unknown): boolean => {
return Array.isArray(children) && children.length > 0;
};
export const shouldLoadSidebarNodeOnExpand = (
node: Pick<TreeNode, 'type' | 'children' | 'isLeaf'> | null | undefined,
): boolean => {
if (!node || node.isLeaf === true || hasSidebarLazyChildren(node.children)) return false;
return node.type === 'connection'
|| node.type === 'database'
|| node.type === 'external-sql-root'
|| node.type === 'table'
|| node.type === 'jvm-mode'
|| node.type === 'jvm-resource';
};
export const resolveSidebarTableNameForCopy = (node: Pick<TreeNode, 'title' | 'dataRef'> | null | undefined): string => {
return String(node?.dataRef?.tableName || node?.title || '').trim();
};
type SidebarTableSortPreference = 'name' | 'frequency';
type SidebarTableEntryForSort = {
tableName: string;
schemaName?: string;
displayName: string;
rowCount?: number;
};
export const isSidebarTablePinned = (
pinnedKeys: string[],
connectionId: string,
dbName: string,
tableName: string,
schemaName = '',
): boolean => {
const key = buildSidebarTablePinKey(connectionId, dbName, tableName, schemaName);
return !!key && pinnedKeys.includes(key);
};
export const sortSidebarTableEntries = <T extends SidebarTableEntryForSort>(
entries: T[],
options: {
connectionId: string;
dbName: string;
sortBy: SidebarTableSortPreference;
tableAccessCount?: Record<string, number>;
pinnedSidebarTables?: string[];
},
): T[] => {
const pinnedKeys = options.pinnedSidebarTables || [];
const accessCount = options.tableAccessCount || {};
const compareByName = (a: T, b: T) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase());
const compareWithinPinnedGroup = (a: T, b: T) => {
if (options.sortBy === 'frequency') {
const keyA = `${options.connectionId}-${options.dbName}-${a.tableName}`;
const keyB = `${options.connectionId}-${options.dbName}-${b.tableName}`;
const countA = accessCount[keyA] || 0;
const countB = accessCount[keyB] || 0;
if (countA !== countB) {
return countB - countA;
}
}
return compareByName(a, b);
};
return [...entries].sort((a, b) => {
const pinnedA = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, a.tableName, a.schemaName || '');
const pinnedB = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, b.tableName, b.schemaName || '');
if (pinnedA !== pinnedB) {
return pinnedA ? -1 : 1;
}
return compareWithinPinnedGroup(a, b);
});
};
export const buildV2SidebarTableSectionedChildren = (
parentKey: string,
tableNodes: TreeNode[],
): TreeNode[] => {
const pinnedTables = tableNodes.filter((node) => node?.dataRef?.pinnedSidebarTable);
if (pinnedTables.length === 0) return tableNodes;
const regularTables = tableNodes.filter((node) => !node?.dataRef?.pinnedSidebarTable);
const buildSectionNode = (kind: 'pinned' | 'all', title: string): TreeNode => ({
title,
key: `${parentKey}-v2-${kind}-tables-section`,
type: 'v2-table-section',
isLeaf: true,
selectable: false,
dataRef: {
sectionKind: kind,
},
});
return [
buildSectionNode('pinned', '置顶'),
...pinnedTables,
buildSectionNode('all', '全部'),
...regularTables,
];
};
export const buildSidebarTableChildrenForUi = (
parentKey: string,
tableNodes: TreeNode[],
isV2Ui: boolean,
): TreeNode[] => {
if (!isV2Ui) return tableNodes;
return buildV2SidebarTableSectionedChildren(parentKey, tableNodes);
};
export const formatSidebarRowCount = (count: number): string => {
if (!Number.isFinite(count) || count < 0) return '';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(Math.round(count));
};
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
type BatchObjectType = 'table' | 'view';
type BatchObjectFilterType = 'all' | BatchObjectType;
type BatchSelectionScope = 'filtered' | 'all';
type SearchScope = 'smart' | 'object' | 'database' | 'host' | 'tag';
type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines' | 'events';
export const V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID = '__gonavi-v2-ungrouped-connections__';
export interface V2RailConnectionGroup {
id: string;
name: string;
connections: SavedConnection[];
isUngrouped?: boolean;
rootToken: string;
}
export const buildV2RailConnectionGroups = (
connections: SavedConnection[],
connectionTags: ConnectionTag[],
sidebarRootOrder: string[] = [],
): V2RailConnectionGroup[] => {
const connectionById = new Map(connections.map((conn) => [conn.id, conn]));
const groupedConnectionIds = new Set<string>();
const tagGroups = new Map<string, V2RailConnectionGroup>();
connectionTags.forEach((tag) => {
const tagConnections: SavedConnection[] = [];
tag.connectionIds.forEach((connectionId) => {
const conn = connectionById.get(connectionId);
if (!conn || groupedConnectionIds.has(conn.id)) return;
groupedConnectionIds.add(conn.id);
tagConnections.push(conn);
});
if (tagConnections.length === 0) return;
tagGroups.set(tag.id, {
id: tag.id,
name: tag.name || '未命名分组',
connections: tagConnections,
rootToken: buildSidebarRootTagToken(tag.id),
});
});
const ungroupedConnectionMap = new Map(
connections
.filter((conn) => !groupedConnectionIds.has(conn.id))
.map((conn) => [conn.id, conn]),
);
const orderedRootTokens = resolveSidebarRootOrderTokens(
sidebarRootOrder,
connectionTags,
connections,
);
const groups: V2RailConnectionGroup[] = [];
orderedRootTokens.forEach((token) => {
if (token.startsWith('tag:')) {
const tagId = token.slice('tag:'.length);
const group = tagGroups.get(tagId);
if (!group) return;
groups.push(group);
tagGroups.delete(tagId);
return;
}
if (token.startsWith('connection:')) {
const connectionId = token.slice('connection:'.length);
const conn = ungroupedConnectionMap.get(connectionId);
if (!conn) return;
groups.push({
id: connectionId,
name: conn.name,
connections: [conn],
isUngrouped: true,
rootToken: buildSidebarRootConnectionToken(connectionId),
});
ungroupedConnectionMap.delete(connectionId);
}
});
tagGroups.forEach((group) => {
groups.push(group);
});
ungroupedConnectionMap.forEach((conn) => {
groups.push({
id: conn.id,
name: conn.name,
connections: [conn],
isUngrouped: true,
rootToken: buildSidebarRootConnectionToken(conn.id),
});
});
return groups;
};
export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = '组'): string => {
const trimmed = String(name ?? '').trim();
if (!trimmed) return fallback;
const cjkParts = trimmed.match(/[\u4e00-\u9fa5]/g);
if (cjkParts && cjkParts.length > 0) {
return cjkParts.slice(0, 1).join('');
}
const latinTokens = trimmed.match(/[a-z0-9]+/gi) || [];
if (latinTokens.length >= 2) {
const firstToken = latinTokens[0] || '';
const secondToken = latinTokens[1] || '';
return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase();
}
if (latinTokens.length === 1) {
const token = latinTokens[0] || '';
const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || '';
if (alphaPrefix) {
return alphaPrefix.slice(0, 2).toUpperCase();
}
const trailingDigits = token.match(/(\d{2,})$/)?.[1];
if (trailingDigits) {
return trailingDigits.slice(-2).toUpperCase();
}
return token.slice(0, 2).toUpperCase();
}
return trimmed.slice(0, 2);
};
const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'tables', label: '表' },
{ key: 'views', label: '视图' },
{ key: 'routines', label: '函数' },
{ key: 'events', label: '事件' },
];
const V2_EXPLORER_FILTER_GROUP_KEYS: Record<Exclude<V2ExplorerFilter, 'all'>, string[]> = {
tables: ['tables'],
views: ['views', 'materializedViews'],
routines: ['routines'],
events: ['events'],
};
const V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH = 960;
const V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH = 88;
const V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH = 24;
const V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH = 8;
const V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER = 48;
const V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE = 32;
export const estimateV2TreeHorizontalScrollWidth = (
nodes: TreeNode[],
viewportWidth: number,
): number | undefined => {
const safeViewportWidth = Math.max(0, Math.ceil(viewportWidth || 0));
let estimatedContentWidth = safeViewportWidth;
const visit = (items: TreeNode[], depth: number) => {
items.forEach((node) => {
const title = String(node?.title || '');
const metaText = node?.dataRef?.groupKey === 'tables' && Array.isArray(node.children)
? String(node.children.length)
: '';
const nodeWidth = V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH
+ (depth * V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH)
+ ((title.length + metaText.length) * V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH);
estimatedContentWidth = Math.max(estimatedContentWidth, nodeWidth);
if (node.children?.length) {
visit(node.children, depth + 1);
}
});
};
visit(nodes, 0);
if (estimatedContentWidth <= safeViewportWidth + 8) {
return undefined;
}
const scrollWidth = Math.min(
V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH,
Math.max(safeViewportWidth + V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER, Math.ceil(estimatedContentWidth)),
);
return scrollWidth;
};
export const filterV2ExplorerTreeByKind = (
nodes: TreeNode[],
filter: V2ExplorerFilter,
): TreeNode[] => {
if (filter === 'all') return nodes;
const allowedGroupKeys = new Set(V2_EXPLORER_FILTER_GROUP_KEYS[filter]);
const objectTypeMatches = (node: TreeNode): boolean => {
if (filter === 'tables') return node.type === 'table';
if (filter === 'views') return node.type === 'view' || node.type === 'materialized-view';
if (filter === 'routines') return node.type === 'routine';
if (filter === 'events') return node.type === 'db-event';
return false;
};
const visit = (node: TreeNode): TreeNode | null => {
if (node.type === 'external-sql-root') {
return null;
}
const groupKey = String(node?.dataRef?.groupKey || '');
if (node.type === 'object-group') {
if (allowedGroupKeys.has(groupKey)) {
return node;
}
if (groupKey === 'schema') {
const schemaChildren = (node.children || []).map(visit).filter(Boolean) as TreeNode[];
return schemaChildren.length > 0 ? { ...node, children: schemaChildren, isLeaf: false } : null;
}
return null;
}
if (objectTypeMatches(node)) {
return node;
}
if (node.type === 'database') {
const filteredChildren = (node.children || []).map(visit).filter(Boolean) as TreeNode[];
return filteredChildren.length > 0 ? { ...node, children: filteredChildren, isLeaf: false } : null;
}
return null;
};
return nodes.map(visit).filter(Boolean) as TreeNode[];
};
interface BatchObjectItem {
title: string;
@@ -525,387 +261,6 @@ interface BatchObjectItem {
dataRef: any;
}
export type V2CommandSearchItem =
| {
key: string;
kind: 'node';
title: string;
meta: string;
icon: React.ReactNode;
node: TreeNode;
}
| {
key: string;
kind: 'action';
title: string;
meta: string;
shortcut?: string;
icon: React.ReactNode;
onRun: () => void;
}
| {
key: string;
kind: 'recent';
title: string;
meta: string;
icon: React.ReactNode;
sql: string;
connectionId?: string;
dbName?: string;
};
export type V2CommandSearchMode = 'default' | 'object' | 'ai';
export interface V2CommandSearchQuery {
mode: V2CommandSearchMode;
rawValue: string;
keyword: string;
normalizedKeyword: string;
aiPrompt: string;
}
export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery => {
const rawValue = String(value ?? '');
const trimmedValue = rawValue.trim();
const firstChar = trimmedValue.charAt(0);
if (firstChar === '@' || firstChar === '') {
const keyword = trimmedValue.slice(1).trim();
return {
mode: 'object',
rawValue,
keyword,
normalizedKeyword: keyword.toLowerCase(),
aiPrompt: '',
};
}
if (firstChar === '?' || firstChar === '') {
const aiPrompt = trimmedValue.slice(1).trim();
return {
mode: 'ai',
rawValue,
keyword: aiPrompt,
normalizedKeyword: aiPrompt.toLowerCase(),
aiPrompt,
};
}
return {
mode: 'default',
rawValue,
keyword: trimmedValue,
normalizedKeyword: trimmedValue.toLowerCase(),
aiPrompt: '',
};
};
const isV2CommandSearchObjectNode = (node: TreeNode): boolean => {
return node.type === 'table'
|| node.type === 'view'
|| node.type === 'materialized-view';
};
const V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT = 24;
export const filterV2CommandSearchTreeItems = (
items: V2CommandSearchItem[],
query: V2CommandSearchQuery,
): V2CommandSearchItem[] => {
if (query.mode === 'ai') return [];
const normalizedKeyword = query.normalizedKeyword;
const objectMode = query.mode === 'object';
const matchedItems = items.filter((item) => {
if (item.kind !== 'node') return false;
const node = item.node;
const dataRef = node.dataRef || {};
if (objectMode && !isV2CommandSearchObjectNode(node)) {
return false;
}
if (!normalizedKeyword) return true;
const objectName = String(dataRef.tableName || dataRef.viewName || item.title || '').toLowerCase();
if (objectMode) {
return objectName.includes(normalizedKeyword)
|| String(item.title || '').toLowerCase().includes(normalizedKeyword);
}
const haystack = [
item.title,
item.meta,
dataRef.tableName,
dataRef.viewName,
dataRef.dbName,
dataRef.name,
dataRef.config?.host,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(normalizedKeyword);
});
return normalizedKeyword ? matchedItems : matchedItems.slice(0, V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT);
};
export interface V2CommandSearchEnterState {
key: string;
isComposing?: boolean;
keyCode?: number;
activeItemCount: number;
}
export const shouldRunV2CommandSearchEnter = ({
key,
isComposing,
keyCode,
activeItemCount,
}: V2CommandSearchEnterState): boolean => {
if (key !== 'Enter') return false;
if (isComposing || keyCode === 229) return false;
return activeItemCount > 0;
};
export interface V2CommandSearchPersistentFilterState {
commandSearchValue: string;
persistedFilter: string;
enabled: boolean;
isOpen: boolean;
}
export const resolveV2CommandSearchPersistentFilter = ({
commandSearchValue,
persistedFilter,
enabled,
isOpen,
}: V2CommandSearchPersistentFilterState): string => {
if (!enabled) return '';
if (!isOpen) return String(persistedFilter ?? '').trim();
return String(commandSearchValue ?? '').trim();
};
export interface V2CommandSearchGlobalKeyState {
key: string;
isOpen: boolean;
}
export const shouldCloseV2CommandSearchOnGlobalKey = ({
key,
isOpen,
}: V2CommandSearchGlobalKeyState): boolean => {
if (!isOpen) return false;
const normalizedKey = String(key || '').toLowerCase();
return normalizedKey === 'escape' || normalizedKey === 'esc';
};
export const resolveSidebarConnectionIdFromKey = (
key: unknown,
connectionIds: string[],
): string => {
const keyText = String(key ?? '').trim();
if (!keyText) return '';
const sortedIds = Array.from(new Set(connectionIds.filter(Boolean)))
.sort((a, b) => b.length - a.length);
return sortedIds.find((id) => keyText === id || keyText.startsWith(`${id}-`)) || '';
};
export const resolveSidebarNodeConnectionId = (
node: { key?: unknown; dataRef?: Record<string, unknown> } | null | undefined,
connectionIds: string[],
): string => {
const directId = String(node?.dataRef?.id || node?.dataRef?.connectionId || '').trim();
if (directId && connectionIds.includes(directId)) return directId;
return resolveSidebarConnectionIdFromKey(node?.key, connectionIds);
};
export const normalizeSidebarTreeRelativeDropPosition = (
absoluteDropPosition: number,
nodePos: unknown,
): number => {
const segments = String(nodePos || '').split('-');
const tailIndex = Number(segments[segments.length - 1] || 0);
return absoluteDropPosition - tailIndex;
};
export const resolveSidebarDropInsertBefore = (
relativeDropPosition: number,
metrics?: {
clientY?: number;
top?: number;
height?: number;
} | null,
): boolean => {
if (relativeDropPosition < 0) return true;
if (relativeDropPosition > 0) return false;
const clientY = metrics?.clientY;
const top = metrics?.top;
const height = metrics?.height;
if (
typeof clientY !== 'number'
|| typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(clientY)
|| !Number.isFinite(top)
|| !Number.isFinite(height)
|| height <= 0
) {
return false;
}
return clientY < (top + height / 2);
};
const resolveSidebarDropBaseElementFromDomEvent = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): Element | null => {
if (typeof document === 'undefined') return null;
const fallbackTarget = event?.target && typeof (event.target as any).closest === 'function'
? (event.target as unknown as Element)
: null;
const pointTarget = (
typeof event?.clientX === 'number'
&& typeof event?.clientY === 'number'
)
? document.elementFromPoint(event.clientX, event.clientY)
: null;
const baseElement = pointTarget || fallbackTarget;
if (!baseElement || typeof baseElement.closest !== 'function') return null;
return baseElement;
};
export const resolveSidebarDropNodeFromDomEvent = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): { key: string; type: string } | null => {
const baseElement = resolveSidebarDropBaseElementFromDomEvent(event);
if (!baseElement) return null;
const marker = baseElement.closest('[data-sidebar-node-key]') as HTMLElement | null;
if (!marker) return null;
const key = String(marker.getAttribute('data-sidebar-node-key') || '').trim();
const type = String(marker.getAttribute('data-sidebar-node-type') || '').trim();
if (!key || !type) return null;
return { key, type };
};
export const resolveSidebarDropTargetMetricsFromDomEvent = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): { top: number; height: number } | null => {
const baseElement = resolveSidebarDropBaseElementFromDomEvent(event);
if (!baseElement) return null;
const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;
if (!treeNode || typeof treeNode.getBoundingClientRect !== 'function') return null;
const rect = treeNode.getBoundingClientRect();
if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) {
return null;
}
return {
top: rect.top,
height: rect.height,
};
};
export const resolveSidebarTagDropInsertBefore = (options: {
currentTagOrder: string[];
dragTagId: string;
dropTagId: string;
relativeDropPosition: number;
fallbackInsertBefore: boolean;
metrics?: {
clientY?: number;
top?: number;
height?: number;
} | null;
}): boolean => {
const {
currentTagOrder,
dragTagId,
dropTagId,
relativeDropPosition,
fallbackInsertBefore,
metrics,
} = options;
if (relativeDropPosition !== 0) {
return fallbackInsertBefore;
}
const clientY = metrics?.clientY;
const top = metrics?.top;
const height = metrics?.height;
if (
typeof clientY !== 'number'
|| typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(clientY)
|| !Number.isFinite(top)
|| !Number.isFinite(height)
|| height <= 0
) {
return fallbackInsertBefore;
}
const ratio = (clientY - top) / height;
if (ratio < 0.35) return true;
if (ratio > 0.65) return false;
const dragIndex = currentTagOrder.indexOf(dragTagId);
const dropIndex = currentTagOrder.indexOf(dropTagId);
if (dragIndex === -1 || dropIndex === -1 || dragIndex === dropIndex) {
return fallbackInsertBefore;
}
return dragIndex > dropIndex;
};
export const shouldSkipSidebarSelectWhileDragging = (
isTreeDragging: boolean,
info: { selected?: boolean } | null | undefined,
): boolean => isTreeDragging || !info?.selected;
export const shouldSkipSidebarLoadOnExpandWhileDragging = (
isTreeDragging: boolean,
info: { expanded?: boolean; node?: Pick<TreeNode, 'type' | 'children' | 'isLeaf'> | null } | null | undefined,
): boolean => {
if (isTreeDragging) return true;
if (!info?.expanded) return true;
return !shouldLoadSidebarNodeOnExpand(info.node);
};
export const resolveV2ActiveConnectionId = ({
activeContextConnectionId,
activeTabConnectionId,
selectedKeys,
connectionIds,
fallbackConnectionId,
}: {
activeContextConnectionId?: unknown;
activeTabConnectionId?: unknown;
selectedKeys: unknown[];
connectionIds: string[];
fallbackConnectionId?: unknown;
}): string => {
const connectionIdSet = new Set(connectionIds);
const normalizeDirectId = (value: unknown): string => {
const text = String(value || '').trim();
return text && connectionIdSet.has(text) ? text : '';
};
const selectedConnectionId = selectedKeys
.map((key) => resolveSidebarConnectionIdFromKey(key, connectionIds))
.find(Boolean) || '';
return normalizeDirectId(activeContextConnectionId)
|| selectedConnectionId
|| normalizeDirectId(fallbackConnectionId)
|| normalizeDirectId(activeTabConnectionId)
|| '';
};
export const shouldClearSidebarActiveContextOnEmptySelect = (isV2Ui: boolean): boolean => !isV2Ui;
type DriverStatusSnapshot = {
type: string;
name: string;

View File

@@ -0,0 +1,764 @@
import type { ReactNode } from 'react';
import {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
buildSidebarTablePinKey,
resolveSidebarRootOrderTokens,
} from '../store';
import type { ConnectionTag, SavedConnection } from '../types';
export type SidebarTreeNodeType =
| 'connection'
| 'database'
| 'table'
| 'view'
| 'materialized-view'
| 'db-trigger'
| 'db-event'
| 'routine'
| 'object-group'
| 'v2-table-section'
| 'queries-folder'
| 'saved-query'
| 'external-sql-root'
| 'external-sql-directory'
| 'external-sql-folder'
| 'external-sql-file'
| 'folder-columns'
| 'folder-indexes'
| 'folder-fks'
| 'folder-triggers'
| 'redis-db'
| 'tag'
| 'jvm-mode'
| 'jvm-resource'
| 'jvm-diagnostic'
| 'jvm-monitoring';
export interface SidebarTreeNode {
title: string;
key: string;
isLeaf?: boolean;
selectable?: boolean;
children?: SidebarTreeNode[];
icon?: ReactNode;
dataRef?: any;
type?: SidebarTreeNodeType;
}
export const hasSidebarLazyChildren = (children: unknown): boolean => {
return Array.isArray(children) && children.length > 0;
};
export const shouldLoadSidebarNodeOnExpand = (
node: Pick<SidebarTreeNode, 'type' | 'children' | 'isLeaf'> | null | undefined,
): boolean => {
if (!node || node.isLeaf === true || hasSidebarLazyChildren(node.children)) return false;
return node.type === 'connection'
|| node.type === 'database'
|| node.type === 'external-sql-root'
|| node.type === 'table'
|| node.type === 'jvm-mode'
|| node.type === 'jvm-resource';
};
export const resolveSidebarTableNameForCopy = (
node: Pick<SidebarTreeNode, 'title' | 'dataRef'> | null | undefined,
): string => {
return String(node?.dataRef?.tableName || node?.title || '').trim();
};
type SidebarTableSortPreference = 'name' | 'frequency';
type SidebarTableEntryForSort = {
tableName: string;
schemaName?: string;
displayName: string;
rowCount?: number;
};
export const isSidebarTablePinned = (
pinnedKeys: string[],
connectionId: string,
dbName: string,
tableName: string,
schemaName = '',
): boolean => {
const key = buildSidebarTablePinKey(connectionId, dbName, tableName, schemaName);
return !!key && pinnedKeys.includes(key);
};
export const sortSidebarTableEntries = <T extends SidebarTableEntryForSort>(
entries: T[],
options: {
connectionId: string;
dbName: string;
sortBy: SidebarTableSortPreference;
tableAccessCount?: Record<string, number>;
pinnedSidebarTables?: string[];
},
): T[] => {
const pinnedKeys = options.pinnedSidebarTables || [];
const accessCount = options.tableAccessCount || {};
const compareByName = (a: T, b: T) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase());
const compareWithinPinnedGroup = (a: T, b: T) => {
if (options.sortBy === 'frequency') {
const keyA = `${options.connectionId}-${options.dbName}-${a.tableName}`;
const keyB = `${options.connectionId}-${options.dbName}-${b.tableName}`;
const countA = accessCount[keyA] || 0;
const countB = accessCount[keyB] || 0;
if (countA !== countB) {
return countB - countA;
}
}
return compareByName(a, b);
};
return [...entries].sort((a, b) => {
const pinnedA = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, a.tableName, a.schemaName || '');
const pinnedB = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, b.tableName, b.schemaName || '');
if (pinnedA !== pinnedB) {
return pinnedA ? -1 : 1;
}
return compareWithinPinnedGroup(a, b);
});
};
export const buildV2SidebarTableSectionedChildren = (
parentKey: string,
tableNodes: SidebarTreeNode[],
): SidebarTreeNode[] => {
const pinnedTables = tableNodes.filter((node) => node?.dataRef?.pinnedSidebarTable);
if (pinnedTables.length === 0) return tableNodes;
const regularTables = tableNodes.filter((node) => !node?.dataRef?.pinnedSidebarTable);
const buildSectionNode = (kind: 'pinned' | 'all', title: string): SidebarTreeNode => ({
title,
key: `${parentKey}-v2-${kind}-tables-section`,
type: 'v2-table-section',
isLeaf: true,
selectable: false,
dataRef: {
sectionKind: kind,
},
});
return [
buildSectionNode('pinned', '置顶'),
...pinnedTables,
buildSectionNode('all', '全部'),
...regularTables,
];
};
export const buildSidebarTableChildrenForUi = (
parentKey: string,
tableNodes: SidebarTreeNode[],
isV2Ui: boolean,
): SidebarTreeNode[] => {
if (!isV2Ui) return tableNodes;
return buildV2SidebarTableSectionedChildren(parentKey, tableNodes);
};
export const formatSidebarRowCount = (count: number): string => {
if (!Number.isFinite(count) || count < 0) return '';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(Math.round(count));
};
export interface V2RailConnectionGroup {
id: string;
name: string;
connections: SavedConnection[];
isUngrouped?: boolean;
rootToken: string;
}
export const buildV2RailConnectionGroups = (
connections: SavedConnection[],
connectionTags: ConnectionTag[],
sidebarRootOrder: string[] = [],
): V2RailConnectionGroup[] => {
const connectionById = new Map(connections.map((conn) => [conn.id, conn]));
const groupedConnectionIds = new Set<string>();
const tagGroups = new Map<string, V2RailConnectionGroup>();
connectionTags.forEach((tag) => {
const tagConnections: SavedConnection[] = [];
tag.connectionIds.forEach((connectionId) => {
const conn = connectionById.get(connectionId);
if (!conn || groupedConnectionIds.has(conn.id)) return;
groupedConnectionIds.add(conn.id);
tagConnections.push(conn);
});
if (tagConnections.length === 0) return;
tagGroups.set(tag.id, {
id: tag.id,
name: tag.name || '未命名分组',
connections: tagConnections,
rootToken: buildSidebarRootTagToken(tag.id),
});
});
const ungroupedConnectionMap = new Map(
connections
.filter((conn) => !groupedConnectionIds.has(conn.id))
.map((conn) => [conn.id, conn]),
);
const orderedRootTokens = resolveSidebarRootOrderTokens(
sidebarRootOrder,
connectionTags,
connections,
);
const groups: V2RailConnectionGroup[] = [];
orderedRootTokens.forEach((token) => {
if (token.startsWith('tag:')) {
const tagId = token.slice('tag:'.length);
const group = tagGroups.get(tagId);
if (!group) return;
groups.push(group);
tagGroups.delete(tagId);
return;
}
if (token.startsWith('connection:')) {
const connectionId = token.slice('connection:'.length);
const conn = ungroupedConnectionMap.get(connectionId);
if (!conn) return;
groups.push({
id: connectionId,
name: conn.name,
connections: [conn],
isUngrouped: true,
rootToken: buildSidebarRootConnectionToken(connectionId),
});
ungroupedConnectionMap.delete(connectionId);
}
});
tagGroups.forEach((group) => {
groups.push(group);
});
ungroupedConnectionMap.forEach((conn) => {
groups.push({
id: conn.id,
name: conn.name,
connections: [conn],
isUngrouped: true,
rootToken: buildSidebarRootConnectionToken(conn.id),
});
});
return groups;
};
export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = '组'): string => {
const trimmed = String(name ?? '').trim();
if (!trimmed) return fallback;
const cjkParts = trimmed.match(/[\u4e00-\u9fa5]/g);
if (cjkParts && cjkParts.length > 0) {
return cjkParts.slice(0, 1).join('');
}
const latinTokens = trimmed.match(/[a-z0-9]+/gi) || [];
if (latinTokens.length >= 2) {
const firstToken = latinTokens[0] || '';
const secondToken = latinTokens[1] || '';
return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase();
}
if (latinTokens.length === 1) {
const token = latinTokens[0] || '';
const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || '';
if (alphaPrefix) {
return alphaPrefix.slice(0, 2).toUpperCase();
}
const trailingDigits = token.match(/(\d{2,})$/)?.[1];
if (trailingDigits) {
return trailingDigits.slice(-2).toUpperCase();
}
return token.slice(0, 2).toUpperCase();
}
return trimmed.slice(0, 2);
};
export type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines' | 'events';
export const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'tables', label: '表' },
{ key: 'views', label: '视图' },
{ key: 'routines', label: '函数' },
{ key: 'events', label: '事件' },
];
const V2_EXPLORER_FILTER_GROUP_KEYS: Record<Exclude<V2ExplorerFilter, 'all'>, string[]> = {
tables: ['tables'],
views: ['views', 'materializedViews'],
routines: ['routines'],
events: ['events'],
};
const V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH = 960;
const V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH = 88;
const V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH = 24;
const V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH = 8;
const V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER = 48;
export const V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE = 32;
export const estimateV2TreeHorizontalScrollWidth = (
nodes: SidebarTreeNode[],
viewportWidth: number,
): number | undefined => {
const safeViewportWidth = Math.max(0, Math.ceil(viewportWidth || 0));
let estimatedContentWidth = safeViewportWidth;
const visit = (items: SidebarTreeNode[], depth: number) => {
items.forEach((node) => {
const title = String(node?.title || '');
const metaText = node?.dataRef?.groupKey === 'tables' && Array.isArray(node.children)
? String(node.children.length)
: '';
const nodeWidth = V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH
+ (depth * V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH)
+ ((title.length + metaText.length) * V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH);
estimatedContentWidth = Math.max(estimatedContentWidth, nodeWidth);
if (node.children?.length) {
visit(node.children, depth + 1);
}
});
};
visit(nodes, 0);
if (estimatedContentWidth <= safeViewportWidth + 8) {
return undefined;
}
const scrollWidth = Math.min(
V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH,
Math.max(safeViewportWidth + V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER, Math.ceil(estimatedContentWidth)),
);
return scrollWidth;
};
export const filterV2ExplorerTreeByKind = (
nodes: SidebarTreeNode[],
filter: V2ExplorerFilter,
): SidebarTreeNode[] => {
if (filter === 'all') return nodes;
const allowedGroupKeys = new Set(V2_EXPLORER_FILTER_GROUP_KEYS[filter]);
const objectTypeMatches = (node: SidebarTreeNode): boolean => {
if (filter === 'tables') return node.type === 'table';
if (filter === 'views') return node.type === 'view' || node.type === 'materialized-view';
if (filter === 'routines') return node.type === 'routine';
if (filter === 'events') return node.type === 'db-event';
return false;
};
const visit = (node: SidebarTreeNode): SidebarTreeNode | null => {
if (node.type === 'external-sql-root') {
return null;
}
const groupKey = String(node?.dataRef?.groupKey || '');
if (node.type === 'object-group') {
if (allowedGroupKeys.has(groupKey)) {
return node;
}
if (groupKey === 'schema') {
const schemaChildren = (node.children || []).map(visit).filter(Boolean) as SidebarTreeNode[];
return schemaChildren.length > 0 ? { ...node, children: schemaChildren, isLeaf: false } : null;
}
return null;
}
if (objectTypeMatches(node)) {
return node;
}
if (node.type === 'database') {
const filteredChildren = (node.children || []).map(visit).filter(Boolean) as SidebarTreeNode[];
return filteredChildren.length > 0 ? { ...node, children: filteredChildren, isLeaf: false } : null;
}
return null;
};
return nodes.map(visit).filter(Boolean) as SidebarTreeNode[];
};
export type V2CommandSearchItem =
| {
key: string;
kind: 'node';
title: string;
meta: string;
icon: ReactNode;
node: SidebarTreeNode;
}
| {
key: string;
kind: 'action';
title: string;
meta: string;
shortcut?: string;
icon: ReactNode;
onRun: () => void;
}
| {
key: string;
kind: 'recent';
title: string;
meta: string;
icon: ReactNode;
sql: string;
connectionId?: string;
dbName?: string;
};
export type V2CommandSearchMode = 'default' | 'object' | 'ai';
export interface V2CommandSearchQuery {
mode: V2CommandSearchMode;
rawValue: string;
keyword: string;
normalizedKeyword: string;
aiPrompt: string;
}
export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery => {
const rawValue = String(value ?? '');
const trimmedValue = rawValue.trim();
const firstChar = trimmedValue.charAt(0);
if (firstChar === '@' || firstChar === '') {
const keyword = trimmedValue.slice(1).trim();
return {
mode: 'object',
rawValue,
keyword,
normalizedKeyword: keyword.toLowerCase(),
aiPrompt: '',
};
}
if (firstChar === '?' || firstChar === '') {
const aiPrompt = trimmedValue.slice(1).trim();
return {
mode: 'ai',
rawValue,
keyword: aiPrompt,
normalizedKeyword: aiPrompt.toLowerCase(),
aiPrompt,
};
}
return {
mode: 'default',
rawValue,
keyword: trimmedValue,
normalizedKeyword: trimmedValue.toLowerCase(),
aiPrompt: '',
};
};
const isV2CommandSearchObjectNode = (node: SidebarTreeNode): boolean => {
return node.type === 'table'
|| node.type === 'view'
|| node.type === 'materialized-view';
};
const V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT = 24;
export const filterV2CommandSearchTreeItems = (
items: V2CommandSearchItem[],
query: V2CommandSearchQuery,
): V2CommandSearchItem[] => {
if (query.mode === 'ai') return [];
const normalizedKeyword = query.normalizedKeyword;
const objectMode = query.mode === 'object';
const matchedItems = items.filter((item) => {
if (item.kind !== 'node') return false;
const node = item.node;
const dataRef = node.dataRef || {};
if (objectMode && !isV2CommandSearchObjectNode(node)) {
return false;
}
if (!normalizedKeyword) return true;
const objectName = String(dataRef.tableName || dataRef.viewName || item.title || '').toLowerCase();
if (objectMode) {
return objectName.includes(normalizedKeyword)
|| String(item.title || '').toLowerCase().includes(normalizedKeyword);
}
const haystack = [
item.title,
item.meta,
dataRef.tableName,
dataRef.viewName,
dataRef.dbName,
dataRef.name,
dataRef.config?.host,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(normalizedKeyword);
});
return normalizedKeyword ? matchedItems : matchedItems.slice(0, V2_COMMAND_SEARCH_INITIAL_TREE_LIMIT);
};
export interface V2CommandSearchEnterState {
key: string;
isComposing?: boolean;
keyCode?: number;
activeItemCount: number;
}
export const shouldRunV2CommandSearchEnter = ({
key,
isComposing,
keyCode,
activeItemCount,
}: V2CommandSearchEnterState): boolean => {
if (key !== 'Enter') return false;
if (isComposing || keyCode === 229) return false;
return activeItemCount > 0;
};
export interface V2CommandSearchPersistentFilterState {
commandSearchValue: string;
persistedFilter: string;
enabled: boolean;
isOpen: boolean;
}
export const resolveV2CommandSearchPersistentFilter = ({
commandSearchValue,
persistedFilter,
enabled,
isOpen,
}: V2CommandSearchPersistentFilterState): string => {
if (!enabled) return '';
if (!isOpen) return String(persistedFilter ?? '').trim();
return String(commandSearchValue ?? '').trim();
};
export interface V2CommandSearchGlobalKeyState {
key: string;
isOpen: boolean;
}
export const shouldCloseV2CommandSearchOnGlobalKey = ({
key,
isOpen,
}: V2CommandSearchGlobalKeyState): boolean => {
if (!isOpen) return false;
const normalizedKey = String(key || '').toLowerCase();
return normalizedKey === 'escape' || normalizedKey === 'esc';
};
export const resolveSidebarConnectionIdFromKey = (
key: unknown,
connectionIds: string[],
): string => {
const keyText = String(key ?? '').trim();
if (!keyText) return '';
const sortedIds = Array.from(new Set(connectionIds.filter(Boolean)))
.sort((a, b) => b.length - a.length);
return sortedIds.find((id) => keyText === id || keyText.startsWith(`${id}-`)) || '';
};
export const resolveSidebarNodeConnectionId = (
node: { key?: unknown; dataRef?: Record<string, unknown> } | null | undefined,
connectionIds: string[],
): string => {
const directId = String(node?.dataRef?.id || node?.dataRef?.connectionId || '').trim();
if (directId && connectionIds.includes(directId)) return directId;
return resolveSidebarConnectionIdFromKey(node?.key, connectionIds);
};
export const normalizeSidebarTreeRelativeDropPosition = (
absoluteDropPosition: number,
nodePos: unknown,
): number => {
const segments = String(nodePos || '').split('-');
const tailIndex = Number(segments[segments.length - 1] || 0);
return absoluteDropPosition - tailIndex;
};
export const resolveSidebarDropInsertBefore = (
relativeDropPosition: number,
metrics?: {
clientY?: number;
top?: number;
height?: number;
} | null,
): boolean => {
if (relativeDropPosition < 0) return true;
if (relativeDropPosition > 0) return false;
const clientY = metrics?.clientY;
const top = metrics?.top;
const height = metrics?.height;
if (
typeof clientY !== 'number'
|| typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(clientY)
|| !Number.isFinite(top)
|| !Number.isFinite(height)
|| height <= 0
) {
return false;
}
return clientY < (top + height / 2);
};
const resolveSidebarDropBaseElementFromDomEvent = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): Element | null => {
if (typeof document === 'undefined') return null;
const fallbackTarget = event?.target && typeof (event.target as any).closest === 'function'
? (event.target as unknown as Element)
: null;
const pointTarget = (
typeof event?.clientX === 'number'
&& typeof event?.clientY === 'number'
)
? document.elementFromPoint(event.clientX, event.clientY)
: null;
const baseElement = pointTarget || fallbackTarget;
if (!baseElement || typeof baseElement.closest !== 'function') return null;
return baseElement;
};
export const resolveSidebarDropNodeFromDomEvent = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): { key: string; type: string } | null => {
const baseElement = resolveSidebarDropBaseElementFromDomEvent(event);
if (!baseElement) return null;
const marker = baseElement.closest('[data-sidebar-node-key]') as HTMLElement | null;
if (!marker) return null;
const key = String(marker.getAttribute('data-sidebar-node-key') || '').trim();
const type = String(marker.getAttribute('data-sidebar-node-type') || '').trim();
if (!key || !type) return null;
return { key, type };
};
export const resolveSidebarDropTargetMetricsFromDomEvent = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): { top: number; height: number } | null => {
const baseElement = resolveSidebarDropBaseElementFromDomEvent(event);
if (!baseElement) return null;
const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;
if (!treeNode || typeof treeNode.getBoundingClientRect !== 'function') return null;
const rect = treeNode.getBoundingClientRect();
if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) {
return null;
}
return {
top: rect.top,
height: rect.height,
};
};
export const resolveSidebarTagDropInsertBefore = (options: {
currentTagOrder: string[];
dragTagId: string;
dropTagId: string;
relativeDropPosition: number;
fallbackInsertBefore: boolean;
metrics?: {
clientY?: number;
top?: number;
height?: number;
} | null;
}): boolean => {
const {
currentTagOrder,
dragTagId,
dropTagId,
relativeDropPosition,
fallbackInsertBefore,
metrics,
} = options;
if (relativeDropPosition !== 0) {
return fallbackInsertBefore;
}
const clientY = metrics?.clientY;
const top = metrics?.top;
const height = metrics?.height;
if (
typeof clientY !== 'number'
|| typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(clientY)
|| !Number.isFinite(top)
|| !Number.isFinite(height)
|| height <= 0
) {
return fallbackInsertBefore;
}
const ratio = (clientY - top) / height;
if (ratio < 0.35) return true;
if (ratio > 0.65) return false;
const dragIndex = currentTagOrder.indexOf(dragTagId);
const dropIndex = currentTagOrder.indexOf(dropTagId);
if (dragIndex === -1 || dropIndex === -1 || dragIndex === dropIndex) {
return fallbackInsertBefore;
}
return dragIndex > dropIndex;
};
export const shouldSkipSidebarSelectWhileDragging = (
isTreeDragging: boolean,
info: { selected?: boolean } | null | undefined,
): boolean => isTreeDragging || !info?.selected;
export const shouldSkipSidebarLoadOnExpandWhileDragging = (
isTreeDragging: boolean,
info: { expanded?: boolean; node?: Pick<SidebarTreeNode, 'type' | 'children' | 'isLeaf'> | null } | null | undefined,
): boolean => {
if (isTreeDragging) return true;
if (!info?.expanded) return true;
return !shouldLoadSidebarNodeOnExpand(info.node);
};
export const resolveV2ActiveConnectionId = ({
activeContextConnectionId,
activeTabConnectionId,
selectedKeys,
connectionIds,
fallbackConnectionId,
}: {
activeContextConnectionId?: unknown;
activeTabConnectionId?: unknown;
selectedKeys: unknown[];
connectionIds: string[];
fallbackConnectionId?: unknown;
}): string => {
const connectionIdSet = new Set(connectionIds);
const normalizeDirectId = (value: unknown): string => {
const text = String(value || '').trim();
return text && connectionIdSet.has(text) ? text : '';
};
const selectedConnectionId = selectedKeys
.map((key) => resolveSidebarConnectionIdFromKey(key, connectionIds))
.find(Boolean) || '';
return normalizeDirectId(activeContextConnectionId)
|| selectedConnectionId
|| normalizeDirectId(fallbackConnectionId)
|| normalizeDirectId(activeTabConnectionId)
|| '';
};
export const shouldClearSidebarActiveContextOnEmptySelect = (isV2Ui: boolean): boolean => !isV2Ui;