♻️ refactor(sidebar): 抽出 V2 右键菜单逻辑

This commit is contained in:
Syngnat
2026-06-19 18:08:54 +08:00
parent 1dea343aa2
commit 13705f9098
3 changed files with 565 additions and 421 deletions

View File

@@ -71,6 +71,7 @@ const readSidebarSource = () => [
readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'),
readSourceFile('./sidebar/SidebarEntityModals.tsx'),
readSourceFile('./sidebar/SidebarTreeTitle.tsx'),
readSourceFile('./sidebar/useSidebarV2ContextMenu.tsx'),
readSourceFile('./sidebarV2Utils.ts'),
].join('\n');
const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx');

View File

@@ -20,6 +20,10 @@ import {
import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals';
import { SidebarEntityModals } from './sidebar/SidebarEntityModals';
import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle';
import {
useSidebarV2ContextMenu,
type SidebarContextMenuState,
} from './sidebar/useSidebarV2ContextMenu';
import {
normalizeDriverType,
useSidebarTreeLoaders,
@@ -239,19 +243,6 @@ export {
export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils';
const { Search } = Input;
type SidebarContextMenuState = {
x: number;
y: number;
sourceX?: number;
sourceY?: number;
items: MenuProps['items'];
kind?: 'v2-table' | 'v2-database' | 'v2-schema' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group';
node?: any;
rootClassName?: string;
overlayStyle?: React.CSSProperties;
maxHeight?: number;
};
const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;
const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
@@ -593,9 +584,6 @@ const Sidebar: React.FC<{
activeContext: null,
});
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
const unmatchedSavedQueries = useMemo(
@@ -3744,411 +3732,42 @@ const Sidebar: React.FC<{
justifyContent: 'center',
};
const connectionStatusMap = useMemo(() => {
const statusMap = new Map<string, 'live' | 'error' | 'idle'>();
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 match = String(node.title || '').match(/\((\d+)\)/);
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<string, any>, 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 (
<V2TableContextMenuView
tableName={tableName}
shortcutPlatform={activeShortcutPlatform}
stats={stats}
isPinned={isPinned}
supportsTruncate={supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver)}
supportsStarRocksRollup={isStarRocks}
supportsMessagePublish={supportsMessagePublish}
onAction={(action) => {
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 (
<V2TableGroupContextMenuView
shortcutPlatform={activeShortcutPlatform}
dbName={String(groupData.dbName || '')}
count={Array.isArray(node.children) ? node.children.length : 0}
currentSort={currentSort}
onAction={(action) => {
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 (
<V2DatabaseContextMenuView
dbName={String(node.dataRef?.dbName || node.title || '')}
shortcutPlatform={activeShortcutPlatform}
dialect={dialect}
supportsSchemaActions={isPostgresSchemaDialect(dialect)}
supportsStarRocksActions={dialect === 'starrocks'}
supportsRenameDatabase={capabilities.supportsRenameDatabase}
supportsDropDatabase={capabilities.supportsDropDatabase}
onAction={(action) => {
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) => (
<V2SchemaContextMenuView
dbName={String(node?.dataRef?.dbName || '')}
schemaName={String(node?.dataRef?.schemaName || node?.title || '')}
shortcutPlatform={activeShortcutPlatform}
onAction={(action) => {
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 (
<V2ConnectionContextMenuView
connectionName={String(conn?.name || node.title || t('connection.unnamed'))}
shortcutPlatform={activeShortcutPlatform}
hostSummary={resolveConnectionHostSummary(conn?.config)}
driverLabel={resolveConnectionIconType(conn)}
isRedis={conn?.config?.type === 'redis'}
supportsCreateDatabase={capabilities.supportsCreateDatabase}
tags={connectionTags.map((tag) => ({
id: tag.id,
name: tag.name,
selected: tag.id === currentTagId,
}))}
onAction={(action) => {
setContextMenu(null);
handleV2ConnectionContextMenuAction(node, action);
}}
/>
);
};
const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => (
<V2ConnectionGroupContextMenuView
groupName={group.name}
count={group.connections.length}
onAction={(action) => {
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<string, any>;
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);
};
const {
contextMenu,
setContextMenu,
contextMenuPortalRef,
buildRailConnectionStatus,
openV2ConnectionContextMenu,
getV2TreeMetaText,
renderV2SidebarContextMenuContent,
fetchV2TableContextMenuStats,
refreshV2TableContextMenuStats,
} = 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,
});
const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({
node,

View File

@@ -0,0 +1,524 @@
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<string, 'success' | 'error'>;
connectionTags: Array<{ id: string; name: string; connectionIds: string[] }>;
activeShortcutPlatform: any;
flattenConnectionNodes: (nodes: TreeNode[]) => TreeNode[];
v2TreeMetrics: {
databaseTableCounts: Map<React.Key, number>;
objectGroupCounts: Map<React.Key, number>;
};
tableSortPreference: Record<string, any>;
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<void>;
getDatabaseNodeRef: (connRef: any, dbName: string) => any;
handleExportSchemaSQL: (node: any, includeData: boolean) => Promise<void>;
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<SidebarContextMenuState | null>(null);
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
const connectionStatusMap = useMemo(() => {
const statusMap = new Map<string, 'live' | 'error' | 'idle'>();
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 match = String(node.title || '').match(/\((\d+)\)/);
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<string, any>, 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 (
<V2TableContextMenuView
tableName={tableName}
shortcutPlatform={activeShortcutPlatform}
stats={stats}
isPinned={isPinned}
supportsTruncate={supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver)}
supportsStarRocksRollup={isStarRocks}
supportsMessagePublish={supportsMessagePublish}
onAction={(action) => {
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 (
<V2TableGroupContextMenuView
shortcutPlatform={activeShortcutPlatform}
dbName={String(groupData.dbName || '')}
count={Array.isArray(node.children) ? node.children.length : 0}
currentSort={currentSort}
onAction={(action) => {
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 (
<V2DatabaseContextMenuView
dbName={String(node.dataRef?.dbName || node.title || '')}
shortcutPlatform={activeShortcutPlatform}
dialect={dialect}
supportsSchemaActions={isPostgresSchemaDialect(dialect)}
supportsStarRocksActions={dialect === 'starrocks'}
supportsRenameDatabase={capabilities.supportsRenameDatabase}
supportsDropDatabase={capabilities.supportsDropDatabase}
onAction={(action) => {
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) => (
<V2SchemaContextMenuView
dbName={String(node?.dataRef?.dbName || '')}
schemaName={String(node?.dataRef?.schemaName || node?.title || '')}
shortcutPlatform={activeShortcutPlatform}
onAction={(action) => {
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 (
<V2ConnectionContextMenuView
connectionName={String(conn?.name || node.title || t('connection.unnamed'))}
shortcutPlatform={activeShortcutPlatform}
hostSummary={resolveConnectionHostSummary(conn?.config)}
driverLabel={resolveConnectionIconType(conn)}
isRedis={conn?.config?.type === 'redis'}
supportsCreateDatabase={capabilities.supportsCreateDatabase}
tags={connectionTags.map((tag) => ({
id: tag.id,
name: tag.name,
selected: tag.id === currentTagId,
}))}
onAction={(action) => {
setContextMenu(null);
handleV2ConnectionContextMenuAction(node, action);
}}
/>
);
};
const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => (
<V2ConnectionGroupContextMenuView
groupName={group.name}
count={group.connections.length}
onAction={(action) => {
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<string, any>;
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,
};
};