import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { V2DatabaseContextMenuView, V2ConnectionGroupContextMenuView, V2ConnectionContextMenuView, V2SchemaContextMenuView, V2TableContextMenuView, V2TableGroupContextMenuView, type V2DatabaseContextMenuActionKey, type V2ConnectionGroupContextMenuActionKey, type V2ConnectionContextMenuActionKey, type V2SchemaContextMenuActionKey, type V2TableContextMenuActionKey, type V2TableContextMenuStats, type V2TableGroupContextMenuActionKey, } from '../V2TableContextMenu'; import type { SavedConnection } from '../../types'; import { t } from '../../i18n'; import { DBQuery } from '../../../wailsjs/go/app/App'; import { getCaseInsensitiveRawValue, getCaseInsensitiveValue, getMetadataDialect, splitQualifiedName, buildSidebarTableStatusSQL, escapeSQLLiteral } from './sidebarMetadataLoaders'; import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'; import { resolveConnectionHostSummary } from '../../utils/tabDisplay'; import { resolveConnectionIconType } from '../../utils/connectionVisual'; import { formatSidebarRowCount } from './sidebarHelpers'; import { isSidebarTablePinned, type SidebarTreeNode as TreeNode, type V2RailConnectionGroup } from '../sidebarV2Utils'; import { getTableDataDangerActionMeta, supportsTableTruncateAction } from '../tableDataDangerActions'; import { SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, resolveSidebarContextMenuPosition, } from '../sidebarCoreUtils'; export type SidebarContextMenuState = { x: number; y: number; sourceX?: number; sourceY?: number; items: any; kind?: 'v2-table' | 'v2-database' | 'v2-schema' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group'; node?: any; rootClassName?: string; overlayStyle?: React.CSSProperties; maxHeight?: number; }; type SidebarV2ContextMenuOptions = { connections: SavedConnection[]; connectionStates: Record; connectionTags: Array<{ id: string; name: string; connectionIds: string[] }>; activeShortcutPlatform: any; flattenConnectionNodes: (nodes: TreeNode[]) => TreeNode[]; v2TreeMetrics: { databaseTableCounts: Map; objectGroupCounts: Map; }; tableSortPreference: Record; pinnedSidebarTables: any[]; getConnectionNodeForAction: (conn: SavedConnection) => TreeNode; buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any; extractObjectName: (fullName: string) => string; isPostgresSchemaDialect: (dialect: string) => boolean; loadTables: (node: any) => Promise; getDatabaseNodeRef: (connRef: any, dbName: string) => any; handleExportSchemaSQL: (node: any, includeData: boolean) => Promise; handleDeleteSchema: (node: any) => void; openRenameSchemaModal: (node: any) => void; resolveMessagePublishTarget: (node: any) => unknown; addSqlLog: (log: any) => void; handleV2TableContextMenuAction: (node: any, action: V2TableContextMenuActionKey) => void; handleV2TableGroupContextMenuAction: (node: any, action: V2TableGroupContextMenuActionKey) => void; handleV2DatabaseContextMenuAction: (node: any, action: V2DatabaseContextMenuActionKey) => void; handleV2ConnectionContextMenuAction: (node: any, action: V2ConnectionContextMenuActionKey) => void; handleV2ConnectionGroupContextMenuAction: (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => void; }; export const useSidebarV2ContextMenu = ({ connections, connectionStates, connectionTags, activeShortcutPlatform, flattenConnectionNodes, v2TreeMetrics, tableSortPreference, pinnedSidebarTables, getConnectionNodeForAction, buildRuntimeConfig, extractObjectName, isPostgresSchemaDialect, loadTables, getDatabaseNodeRef, handleExportSchemaSQL, handleDeleteSchema, openRenameSchemaModal, resolveMessagePublishTarget, addSqlLog, handleV2TableContextMenuAction, handleV2TableGroupContextMenuAction, handleV2DatabaseContextMenuAction, handleV2ConnectionContextMenuAction, handleV2ConnectionGroupContextMenuAction, }: SidebarV2ContextMenuOptions) => { const [contextMenu, setContextMenu] = useState(null); const contextMenuPortalRef = useRef(null); const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); const connectionStatusMap = useMemo(() => { const statusMap = new Map(); const sortedConnectionIds = connections .map((conn) => conn.id) .sort((a, b) => b.length - a.length); connections.forEach((conn) => { statusMap.set(conn.id, 'idle'); }); Object.entries(connectionStates).forEach(([key, value]) => { const ownState = statusMap.get(key); if (ownState !== undefined) { statusMap.set(key, value === 'success' ? 'live' : 'error'); return; } if (value !== 'success') return; const ownerId = sortedConnectionIds.find((id) => key.startsWith(`${id}-`)); if (ownerId && statusMap.get(ownerId) === 'idle') { statusMap.set(ownerId, 'live'); } }); return statusMap; }, [connectionStates, connections]); const buildRailConnectionStatus = useCallback((connectionId: string): 'live' | 'error' | 'idle' => { return connectionStatusMap.get(connectionId) || 'idle'; }, [connectionStatusMap]); const openV2ConnectionContextMenu = ( event: React.MouseEvent, connOrNode: SavedConnection | TreeNode, ) => { event.preventDefault(); event.stopPropagation(); const node = (connOrNode as TreeNode).type === 'connection' ? connOrNode as TreeNode : getConnectionNodeForAction(connOrNode as SavedConnection); if (!node?.key || !node?.dataRef) return; const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); setContextMenu({ x: position.x, y: position.y, sourceX: event.clientX, sourceY: event.clientY, items: [], kind: 'v2-connection', node, rootClassName: 'gn-v2-table-context-menu-popup', overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, maxHeight: position.maxHeight, }); }; const getV2TreeMetaText = (node: any): string => { if (node.type === 'tag') { const count = flattenConnectionNodes(node.children || []).length; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'database') { const count = v2TreeMetrics.databaseTableCounts.get(node.key) || 0; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'object-group') { const count = v2TreeMetrics.objectGroupCounts.get(node.key) || 0; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'redis-db') { const keyCount = Number(node?.dataRef?.redisKeyCount); if (Number.isFinite(keyCount) && keyCount > 0) { return keyCount.toLocaleString(); } // Fallback for nodes built before redisKeyCount was tracked; avoid // matching an alias by only reading a trailing count suffix. const match = String(node.title || '').match(/\((\d+)\)\s*$/); return match?.[1] || ''; } if (node.type === 'table') { const rowCount = Number(node?.dataRef?.rowCount); return Number.isFinite(rowCount) && rowCount >= 0 ? formatSidebarRowCount(rowCount) : ''; } return ''; }; const getV2TableContextMenuStatsKey = (node: any): string => { const id = String(node?.dataRef?.id || ''); const dbName = String(node?.dataRef?.dbName || ''); const tableName = String(node?.dataRef?.tableName || node?.title || ''); return `${id}::${dbName}::${tableName}`; }; const readNumericMetadataValue = (row: Record, keys: string[]): number | undefined => { const value = getCaseInsensitiveRawValue(row, keys); if (value === undefined || value === null || value === '') return undefined; const normalized = Number(String(value).replace(/,/g, '')); return Number.isFinite(normalized) ? normalized : undefined; }; const buildV2TableStatusSQL = (node: any): string => { const conn = node.dataRef as SavedConnection & { dbName?: string; tableName?: string; schemaName?: string }; const dialect = getMetadataDialect(conn); const dbName = String(conn?.dbName || '').trim(); const tableName = String(conn?.tableName || node?.title || '').trim(); const objectName = extractObjectName(tableName); const schemaName = String(conn?.schemaName || splitQualifiedName(tableName).schemaName || '').trim(); switch (dialect) { case 'mysql': case 'starrocks': return [ 'SELECT TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length, ENGINE AS engine', 'FROM information_schema.tables', `WHERE table_schema = '${escapeSQLLiteral(dbName)}'`, `AND table_name = '${escapeSQLLiteral(objectName)}'`, 'LIMIT 1', ].join('\n'); case 'postgres': case 'kingbase': case 'vastbase': case 'highgo': case 'opengauss': case 'gaussdb': { const schema = schemaName || 'public'; return [ "SELECT c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, pg_indexes_size(c.oid) AS index_length, 'heap' AS engine", 'FROM pg_class c', 'JOIN pg_namespace n ON n.oid = c.relnamespace', "WHERE c.relkind = 'r'", `AND n.nspname = '${escapeSQLLiteral(schema)}'`, `AND c.relname = '${escapeSQLLiteral(objectName)}'`, 'LIMIT 1', ].join('\n'); } case 'sqlserver': { const safeTable = tableName.replace(/'/g, "''"); return [ 'SELECT SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length, NULL AS engine', 'FROM sys.tables t', 'JOIN sys.indexes i ON t.object_id = i.object_id', 'JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id', 'JOIN sys.allocation_units a ON p.partition_id = a.container_id', `WHERE t.object_id = OBJECT_ID('${safeTable}')`, ].join('\n'); } case 'clickhouse': return [ 'SELECT total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length, engine AS engine', 'FROM system.tables', `WHERE database = '${escapeSQLLiteral(dbName)}'`, `AND name = '${escapeSQLLiteral(objectName)}'`, 'LIMIT 1', ].join('\n'); case 'oracle': case 'dm': { const owner = (schemaName || dbName || '').toUpperCase(); return [ 'SELECT num_rows AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine', 'FROM all_tables', `WHERE owner = '${escapeSQLLiteral(owner)}'`, `AND table_name = '${escapeSQLLiteral(objectName.toUpperCase())}'`, 'FETCH FIRST 1 ROWS ONLY', ].join('\n'); } case 'sqlite': case 'duckdb': return `SELECT COUNT(*) AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine FROM ${tableName}`; default: return ''; } }; const renderV2TableContextMenu = (node: any) => { const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); const statsKey = getV2TableContextMenuStatsKey(node); const stats = v2TableContextMenuStats[statsKey]; const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; const supportsMessagePublish = Boolean(resolveMessagePublishTarget(node)); const isPinned = isSidebarTablePinned( pinnedSidebarTables, String(node?.dataRef?.id || ''), String(node?.dataRef?.dbName || ''), tableName, String(node?.dataRef?.schemaName || ''), ); return ( { setContextMenu(null); handleV2TableContextMenuAction(node, action); }} /> ); }; const renderV2TableGroupContextMenu = (node: any) => { const groupData = node.dataRef || {}; const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; return ( { setContextMenu(null); handleV2TableGroupContextMenuAction(node, action); }} /> ); }; const renderV2DatabaseContextMenu = (node: any) => { const dialect = getMetadataDialect(node.dataRef as SavedConnection); const capabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); return ( { setContextMenu(null); handleV2DatabaseContextMenuAction(node, action); }} /> ); }; const handleV2SchemaContextMenuAction = (node: any, action: V2SchemaContextMenuActionKey) => { switch (action) { case 'rename-schema': openRenameSchemaModal(node); return; case 'refresh-schema': void loadTables(getDatabaseNodeRef(node?.dataRef, String(node?.dataRef?.dbName || '').trim())); return; case 'export-schema': void handleExportSchemaSQL(node, false); return; case 'backup-schema-sql': void handleExportSchemaSQL(node, true); return; case 'drop-schema': handleDeleteSchema(node); return; default: return; } }; const renderV2SchemaContextMenu = (node: any) => ( { setContextMenu(null); handleV2SchemaContextMenuAction(node, action); }} /> ); const renderV2ConnectionContextMenu = (node: any) => { const conn = node.dataRef as SavedConnection; const capabilities = getDataSourceCapabilities(conn?.config); const currentTagId = connectionTags.find((tag) => tag.connectionIds.includes(String(conn.id || node.key)))?.id || ''; return ( ({ id: tag.id, name: tag.name, selected: tag.id === currentTagId, }))} onAction={(action) => { setContextMenu(null); handleV2ConnectionContextMenuAction(node, action); }} /> ); }; const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => ( { setContextMenu(null); handleV2ConnectionGroupContextMenuAction(group, action); }} /> ); const renderV2SidebarContextMenuContent = (menu: SidebarContextMenuState) => { if (!menu.node) return null; if (menu.kind === 'v2-table') return renderV2TableContextMenu(menu.node); if (menu.kind === 'v2-database') return renderV2DatabaseContextMenu(menu.node); if (menu.kind === 'v2-schema') return renderV2SchemaContextMenu(menu.node); if (menu.kind === 'v2-table-group') return renderV2TableGroupContextMenu(menu.node); if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node); if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node); return null; }; useEffect(() => { if (!contextMenu?.kind) return; const onPointerDown = (event: MouseEvent) => { const target = event.target instanceof Node ? event.target : null; if (target && contextMenuPortalRef.current?.contains(target)) return; setContextMenu(null); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setContextMenu(null); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [contextMenu?.kind]); useEffect(() => { if (!contextMenu?.kind) return; const frame = requestAnimationFrame(() => { const portal = contextMenuPortalRef.current; if (!portal) return; const rect = portal.getBoundingClientRect(); const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); const position = resolveSidebarContextMenuPosition(contextMenu.sourceX ?? contextMenu.x, contextMenu.sourceY ?? contextMenu.y, { width: rect.width || SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, height: measuredHeight || SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, }); setContextMenu(prev => { if (!prev?.kind) return prev; if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; }); }); return () => cancelAnimationFrame(frame); }, [contextMenu?.kind, contextMenu?.x, contextMenu?.y]); const fetchV2TableContextMenuStats = async (node: any) => { const statsKey = getV2TableContextMenuStatsKey(node); if (!statsKey || v2TableContextMenuStats[statsKey]?.loading) return; const sql = buildV2TableStatusSQL(node); if (!sql) { setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); return; } setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { ...prev[statsKey], loading: true } })); const startTime = Date.now(); try { const conn = node.dataRef; const res = await DBQuery(buildRuntimeConfig(conn, conn.dbName) as any, conn.dbName || '', sql); if (!res.success || !Array.isArray(res.data) || res.data.length === 0) { setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); return; } const row = res.data[0] as Record; setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { rowCount: readNumericMetadataValue(row, ['table_rows', 'TABLE_ROWS', 'rows', 'num_rows', 'reltuples', 'total_rows']), dataLength: readNumericMetadataValue(row, ['data_length', 'DATA_LENGTH', 'total_bytes']), indexLength: readNumericMetadataValue(row, ['index_length', 'INDEX_LENGTH']), engine: getCaseInsensitiveValue(row, ['engine', 'ENGINE']), }, })); addSqlLog({ id: `${Date.now()}-table-stats`, timestamp: Date.now(), sql, status: 'success', duration: Date.now() - startTime, dbName: conn.dbName, }); } catch (error: any) { setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); addSqlLog({ id: `${Date.now()}-table-stats-error`, timestamp: Date.now(), sql, status: 'error', duration: Date.now() - startTime, message: error?.message || String(error), dbName: node?.dataRef?.dbName, }); } }; const refreshV2TableContextMenuStats = (node: any) => { const statsKey = getV2TableContextMenuStatsKey(node); setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { loading: true } })); void fetchV2TableContextMenuStats(node); }; return { contextMenu, setContextMenu, contextMenuPortalRef, buildRailConnectionStatus, openV2ConnectionContextMenu, getV2TreeMetaText, renderV2SidebarContextMenuContent, fetchV2TableContextMenuStats, refreshV2TableContextMenuStats, }; };