diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index c373914..3d85ea0 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -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,'); }); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9484c24..148c7d5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 { - return Array.isArray(children) && children.length > 0; -}; - -export const shouldLoadSidebarNodeOnExpand = ( - node: Pick | 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 | 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 = ( - entries: T[], - options: { - connectionId: string; - dbName: string; - sortBy: SidebarTableSortPreference; - tableAccessCount?: Record; - 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(); - const tagGroups = new Map(); - - 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, 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 } | 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 | 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; diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts new file mode 100644 index 0000000..2e36a13 --- /dev/null +++ b/frontend/src/components/sidebarV2Utils.ts @@ -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 | 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 | 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 = ( + entries: T[], + options: { + connectionId: string; + dbName: string; + sortBy: SidebarTableSortPreference; + tableAccessCount?: Record; + 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(); + const tagGroups = new Map(); + + 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, 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 } | 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 | 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;