Files
MyGoNavi/frontend/src/components/Sidebar.tsx
Syngnat b388b08226 feat(sql-file-execution): 外部SQL执行切换工作台并优化大文件链路
- 前端将运行外部SQL改为工作台Tab并复用导出式进度视图

- 后端新增仅选择文件元数据接口并优化流式读取与批量拼接

- 补充执行Runner、国际化文案和定向测试
2026-07-03 22:11:49 +08:00

3204 lines
126 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SidebarConnectionRail from './sidebar/SidebarConnectionRail';
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
import SlowQueryRailButton from './sidebar/SlowQueryRailButton';
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
import {
getMetadataDialect,
shouldHideSchemaPrefix,
splitQualifiedName,
} from './sidebar/sidebarMetadataLoaders';
import {
useSidebarBatchExport,
} from './sidebar/useSidebarBatchExport';
import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals';
import { SidebarEntityModals } from './sidebar/SidebarEntityModals';
import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle';
import {
useSidebarV2ContextMenu,
} from './sidebar/useSidebarV2ContextMenu';
import {
useSidebarObjectActions,
type SidebarMessagePublishTarget,
} from './sidebar/useSidebarObjectActions';
import { useSidebarSearchModel } from './sidebar/useSidebarSearchModel';
import { useSidebarV2ActionHandlers } from './sidebar/useSidebarV2ActionHandlers';
import { useSidebarCommandSearchRunner } from './sidebar/useSidebarCommandSearchRunner';
import { useSidebarTitleRender } from './sidebar/useSidebarTitleRender';
import {
normalizeDriverType,
useSidebarTreeLoaders,
} from './sidebar/useSidebarTreeLoaders';
export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders';
import {
ExternalSQLFileModal,
useSidebarExternalSqlWorkflow,
} from './sidebar/SidebarExternalSqlWorkflow';
export {
buildSQLFileExecutionFooter,
SQLFileExecutionProgressContent,
} from './sidebar/SidebarExternalSqlWorkflow';
export type {
SQLFileExecutionProgressState,
SQLFileExecutionStatus,
} from './sidebar/SidebarExternalSqlWorkflow';
import {
V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID,
formatSidebarRowCount,
hasSidebarLazyChildren,
shouldClearSidebarActiveContextOnEmptySelect,
shouldLoadSidebarNodeOnExpand,
getV2RailConnectionGroupBadgeText,
type V2ExplorerFilter,
} from './sidebar/sidebarHelpers';
// 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容
export {
V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID,
formatSidebarRowCount,
hasSidebarLazyChildren,
shouldClearSidebarActiveContextOnEmptySelect,
shouldLoadSidebarNodeOnExpand,
getV2RailConnectionGroupBadgeText,
isV2SidebarObjectNode,
resolveV2ObjectGroupTitle,
resolveSidebarTableNameForCopy,
parseV2CommandSearchQuery,
} from './sidebar/sidebarHelpers';
import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react';
import { createPortal } from 'react-dom';
import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Popover, Tooltip } from 'antd';
import {
CaretDownFilled,
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
ExportOutlined,
FolderAddOutlined,
SaveOutlined,
EditOutlined,
SearchOutlined,
KeyOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
FunctionOutlined,
LinkOutlined,
FileAddOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
DeleteOutlined,
DisconnectOutlined,
CheckSquareOutlined,
FilterOutlined,
DashboardOutlined,
WarningOutlined,
AimOutlined,
MoreOutlined,
ToolOutlined,
SettingOutlined
} from '@ant-design/icons';
import {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
resolveSidebarRootOrderTokens,
useStore,
} from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { ListSQLDirectory } from '../../wailsjs/go/app/App';
import { supportsTableTruncateAction } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { isConnectionStructureEditRestricted } from '../utils/connectionReadOnly';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
resolveSidebarRuntimeDatabase,
} from '../utils/sidebarMetadata';
import {
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
type SidebarLocateTreeNodeLike,
} from '../utils/sidebarLocate';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import {
buildBatchDatabaseExportWorkbenchTab,
buildBatchTableExportWorkbenchTab,
} from '../utils/tableExportTab';
import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import { resolveSidebarTableMetadataFields } from '../utils/sidebarTableMetadata';
import { t } from '../i18n';
import MessagePublishModal from './MessagePublishModal';
import {
SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT,
SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH,
resolveSidebarContextMenuPosition,
type SearchScope,
} from './sidebarCoreUtils';
export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils';
export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils';
import {
buildSidebarTableChildrenForUi,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
collectSidebarSubtreeKeys,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
isSidebarTablePinned,
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarDatabaseTreePruneKeys,
resolveSidebarNodeConnectionId,
resolveSidebarTagDropInsertBefore,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldClearSidebarNodeChildrenOnCollapse,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
type SidebarConnectionState,
type SidebarTreeNode as TreeNode,
type V2CommandSearchItem,
} from './sidebarV2Utils';
export {
buildSidebarTableChildrenForUi,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
collectSidebarSubtreeKeys,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
isSidebarTablePinned,
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarDatabaseTreePruneKeys,
resolveSidebarNodeConnectionId,
resolveSidebarTagDropInsertBefore,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldClearSidebarNodeChildrenOnCollapse,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
};
export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils';
type SidebarTreeSwitcherNodeLike = {
key?: React.Key;
data?: TreeNode;
isLeaf?: boolean;
loading?: boolean;
};
export const resolveSidebarSwitcherLoadKey = (node: SidebarTreeSwitcherNodeLike | null | undefined): string | null => {
const treeNode = node?.data;
const dataRef = treeNode?.dataRef;
if (!treeNode) {
return null;
}
if (treeNode.type === 'connection') {
const connectionId = String(dataRef?.id || treeNode.key || node?.key || '').trim();
return connectionId ? `dbs-${connectionId}` : null;
}
if (treeNode.type === 'database') {
const connectionId = String(dataRef?.id || '').trim();
const dbName = String(dataRef?.dbName || '').trim();
return connectionId && dbName ? `tables-${connectionId}-${dbName}` : null;
}
if (treeNode.type === 'jvm-mode' || treeNode.type === 'jvm-resource') {
const connectionId = String(dataRef?.id || '').trim();
const providerMode = String(dataRef?.providerMode || '').trim().toLowerCase();
const parentPath = treeNode.type === 'jvm-resource' ? String(dataRef?.resourcePath || '').trim() : '';
return connectionId && providerMode ? `jvm-resources-${connectionId}-${providerMode}-${parentPath}` : null;
}
return null;
};
export const shouldKeepSidebarSwitcherCollapsedWhileLoading = (
node: SidebarTreeSwitcherNodeLike | null | undefined,
loadingKeys: ReadonlySet<string>,
): boolean => {
if (!node || node.isLeaf) {
return false;
}
if (node.loading) {
return true;
}
const loadKey = resolveSidebarSwitcherLoadKey(node);
return !!loadKey && loadingKeys.has(loadKey);
};
const { Search } = Input;
const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;
const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
const SIDEBAR_CACHED_DATABASE_TREE_LIMIT = 12;
// resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers
// shouldLoadSidebarNodeOnExpand 已迁移到 ./sidebar/sidebarHelpers
// resolveSidebarTableNameForCopy 已迁移到 ./sidebar/sidebarHelpers
const buildConnectionRootQueryTabTitle = () => t('query.new');
const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') =>
t('sidebar.tab.redis_command', { database: redisDbLabel });
const buildConnectionRootRedisMonitorTabTitle = (redisDbLabel = 'db0') =>
t('sidebar.tab.redis_monitor', { database: redisDbLabel });
const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: string }> = [
{ key: 'all', labelKey: 'sidebar.command_search.object_kind.all' },
{ key: 'tables', labelKey: 'sidebar.command_search.object_kind.tables' },
{ key: 'views', labelKey: 'sidebar.command_search.object_kind.views' },
{ key: 'sequences', labelKey: 'sidebar.command_search.object_kind.sequences' },
{ key: 'routines', labelKey: 'sidebar.command_search.object_kind.routines' },
{ key: 'packages', labelKey: 'sidebar.command_search.object_kind.packages' },
{ key: 'events', labelKey: 'sidebar.command_search.object_kind.events' },
];
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
if (!conn) return '';
return JSON.stringify({
config: conn.config || {},
includeDatabases: conn.includeDatabases || [],
includeRedisDatabases: conn.includeRedisDatabases || [],
});
};
const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
const text = String(key);
return text === connectionId || text.startsWith(`${connectionId}-`);
};
const isPostgresSchemaDialect = (dialect: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect))
);
const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set<string>): boolean => (
query.bindingStatus === 'orphan' || !connectionIds.has(query.connectionId)
);
export const buildAllSavedQueriesTreeNode = (
savedQueries: SavedQuery[],
connections: SavedConnection[],
): TreeNode | null => {
if (savedQueries.length === 0) {
return null;
}
const connectionIds = new Set(connections.map((conn) => conn.id));
const unmatchedSavedQueries = savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds));
const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id));
const createQueryNode = (query: SavedQuery): TreeNode => ({
title: query.name || t('sidebar.tree.untitled_query'),
key: `all-saved-query-${query.id}`,
icon: <FileTextOutlined />,
type: 'saved-query',
dataRef: query,
isLeaf: true,
});
const buildDatabaseGroups = (queries: SavedQuery[], keyPrefix: string): TreeNode[] => {
const groupedByDatabase = new Map<string, SavedQuery[]>();
queries.forEach((query) => {
const dbName = String(query.dbName || '').trim() || t('sidebar.tree.default_database');
groupedByDatabase.set(dbName, [...(groupedByDatabase.get(dbName) || []), query]);
});
return Array.from(groupedByDatabase.entries()).map(([dbName, items]) => ({
title: dbName,
key: `${keyPrefix}-db-${encodeURIComponent(dbName)}`,
icon: <DatabaseOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: items.map(createQueryNode),
}));
};
const groupedByConnection = new Map<string, SavedQuery[]>();
savedQueries.forEach((query) => {
if (unmatchedIds.has(query.id)) {
return;
}
groupedByConnection.set(query.connectionId, [
...(groupedByConnection.get(query.connectionId) || []),
query,
]);
});
const children: TreeNode[] = [];
connections.forEach((conn) => {
const connectionQueries = groupedByConnection.get(conn.id);
if (!connectionQueries || connectionQueries.length === 0) {
return;
}
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
children.push({
title: conn.name || conn.id,
key: `all-saved-queries-connection-${conn.id}`,
icon: getDbIcon(iconType, iconColor, 22),
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: buildDatabaseGroups(connectionQueries, `all-saved-queries-connection-${conn.id}`),
});
});
if (unmatchedSavedQueries.length > 0) {
const groupedByOriginalConnection = new Map<string, SavedQuery[]>();
unmatchedSavedQueries.forEach((query) => {
const originalConnectionId = String(query.originalConnectionId || query.connectionId || t('sidebar.tree.unknown_connection')).trim() || t('sidebar.tree.unknown_connection');
groupedByOriginalConnection.set(originalConnectionId, [
...(groupedByOriginalConnection.get(originalConnectionId) || []),
query,
]);
});
children.push({
title: t('sidebar.tree.unmatched_saved_queries'),
key: 'all-saved-queries-unmatched',
icon: <WarningOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: Array.from(groupedByOriginalConnection.entries()).map(([connectionLabel, items]) => ({
title: connectionLabel,
key: `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`,
icon: <FolderOpenOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`),
})),
});
}
return {
title: t('sidebar.tree.all_saved_queries'),
key: 'all-saved-queries',
icon: <FolderOpenOutlined />,
type: 'all-saved-queries',
isLeaf: false,
selectable: false,
children,
};
};
const Sidebar: React.FC<{
onCreateConnection?: () => void;
onEditConnection?: (conn: SavedConnection) => void;
onOpenTools?: () => void;
onOpenSettings?: () => void;
onToggleAI?: () => void;
onToggleLogPanel?: () => void;
uiVersion?: 'legacy' | 'v2';
onFocusCommandSearch?: () => void;
}> = React.memo(({
onCreateConnection,
onEditConnection,
onOpenTools,
onOpenSettings,
onToggleAI,
onToggleLogPanel,
uiVersion,
onFocusCommandSearch,
}) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
const saveQuery = useStore(state => state.saveQuery);
const deleteQuery = useStore(state => state.deleteQuery);
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const connectionTags = useStore(state => state.connectionTags);
const sidebarRootOrder = useStore(state => state.sidebarRootOrder);
const addConnectionTag = useStore(state => state.addConnectionTag);
const updateConnectionTag = useStore(state => state.updateConnectionTag);
const removeConnectionTag = useStore(state => state.removeConnectionTag);
const moveConnectionToTag = useStore(state => state.moveConnectionToTag);
const reorderConnections = useStore(state => state.reorderConnections);
const reorderTags = useStore(state => state.reorderTags);
const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);
const closeTabsByConnection = useStore(state => state.closeTabsByConnection);
const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const activeContext = useStore(state => state.activeContext);
const tableAccessCount = useStore(state => state.tableAccessCount);
const tableSortPreference = useStore(state => state.tableSortPreference);
const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables);
const recordTableAccess = useStore(state => state.recordTableAccess);
const setTableSortPreference = useStore(state => state.setTableSortPreference);
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const addSqlLog = useStore(state => state.addSqlLog);
const sqlLogs = useStore(state => state.sqlLogs) || [];
const shortcutOptions = useStore(state => state.shortcutOptions);
const languagePreference = useStore(state => state.languagePreference);
const setAppearance = useStore(state => state.setAppearance);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const addAIContext = useStore(state => state.addAIContext);
void languagePreference;
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const sidebarTableMetadataFields = useMemo(
() => resolveSidebarTableMetadataFields(
queryOptions?.sidebarTableMetadataFields,
queryOptions?.showSidebarTableComment === true,
queryOptions?.sidebarTableMetadataFieldOrder,
),
[queryOptions?.showSidebarTableComment, queryOptions?.sidebarTableMetadataFieldOrder, queryOptions?.sidebarTableMetadataFields],
);
const { exportProgressModal, runExportWithProgress } = useExportProgressDialog();
const disableLocalBackdropFilter = isMacLikePlatform();
const autoFetchVisible = useAutoFetchVisibility();
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const focusSidebarSearchShortcut = resolveShortcutDisplay(shortcutOptions, 'focusSidebarSearch', activeShortcutPlatform);
const focusSidebarSearchShortcutTokens = focusSidebarSearchShortcut === '-'
? []
: focusSidebarSearchShortcut.match(/Ctrl|Alt|Shift|Esc|Space|[⌘⌃⌥⇧↵↑↓←→]|[^+]/g) ?? [];
const isV2Ui = (uiVersion ?? appearance.uiVersion) === 'v2';
const [treeData, setTreeData] = useState<TreeNode[]>([]);
const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]);
const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]);
const canLocateActiveTab = !!activeTabLocateRequest;
// Background Helper (Duplicate logic for now, ideally shared)
const getBg = (darkHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${opacity})`;
const hex = darkHex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const overlayTheme = useMemo(
() => buildOverlayWorkbenchTheme(darkMode, {
disableBackdropFilter: disableLocalBackdropFilter,
uiVersion: isV2Ui ? 'v2' : 'legacy',
}),
[darkMode, disableLocalBackdropFilter, isV2Ui],
);
const modalPanelStyle = useMemo(() => ({
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalScrollSectionStyle = useMemo(() => ({
maxHeight: 400,
overflow: 'auto' as const,
border: overlayTheme.sectionBorder,
borderRadius: 14,
padding: 12,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalHintTextStyle = useMemo(() => ({
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [overlayTheme]);
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
const v2SidebarSearchMode = appearance.v2SidebarSearchMode ?? 'command';
const v2UseLegacySidebarFilter = isV2Ui && v2SidebarSearchMode === 'filter';
const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true;
const v2PersistedSidebarFilter = appearance.v2SidebarPersistedFilter ?? '';
const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
const [searchValue, setSearchValue] = useState(v2PersistedSidebarFilter);
const deferredSearchValue = useDeferredValue(searchValue);
const [searchScopes, setSearchScopes] = useState<SearchScope[]>(['smart']);
const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('all');
const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false);
const searchInputRef = useRef<any>(null);
const commandSearchInputRef = useRef<any>(null);
const [isV2CommandSearchOpen, setIsV2CommandSearchOpen] = useState(false);
const [v2CommandSearchValue, setV2CommandSearchValue] = useState('');
const deferredV2CommandSearchValue = useDeferredValue(v2CommandSearchValue);
const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const databaseTreeTouchedAtRef = useRef<Record<string, number>>({});
const pruneLoadedDatabaseTreesRef = useRef<() => void>(() => {});
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const treeDragSelectSuppressUntilRef = useRef(0);
const treeDragSelectionSnapshotRef = useRef<{
selectedKeys: React.Key[];
selectedNodes: any[];
activeContext: { connectionId: string; dbName: string } | null;
}>({
selectedKeys: [],
selectedNodes: [],
activeContext: null,
});
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
const unmatchedSavedQueries = useMemo(
() => savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIdSet)),
[connectionIdSet, savedQueries],
);
const allSavedQueriesNode = useMemo<TreeNode | null>(() => {
return buildAllSavedQueriesTreeNode(savedQueries, connections);
}, [connections, savedQueries]);
const snapshotTreeSelectionBeforeDrag = useCallback(() => {
treeDragSelectionSnapshotRef.current = {
selectedKeys: [...selectedKeys],
selectedNodes: [...selectedNodesRef.current],
activeContext: activeContext ? { ...activeContext } : null,
};
}, [activeContext, selectedKeys]);
const restoreTreeSelectionAfterDrag = useCallback(() => {
const snapshot = treeDragSelectionSnapshotRef.current;
treeDragSelectSuppressUntilRef.current = Date.now() + 1000;
setSelectedKeys(snapshot.selectedKeys);
selectedNodesRef.current = snapshot.selectedNodes;
setActiveContext(snapshot.activeContext);
}, [setActiveContext]);
const openV2CommandSearch = useCallback(() => {
pruneLoadedDatabaseTreesRef.current();
setIsV2CommandSearchOpen(true);
setV2CommandActiveIndex(0);
}, []);
const commitV2CommandSearchPersistentFilter = useCallback((value = v2CommandSearchValue) => {
if (!v2CommandSearchPersistentFilterEnabled) {
return;
}
const nextFilter = value.trim();
setSearchValue(nextFilter);
if (nextFilter !== v2PersistedSidebarFilter) {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}
}, [setAppearance, v2CommandSearchPersistentFilterEnabled, v2CommandSearchValue, v2PersistedSidebarFilter]);
const closeV2CommandSearch = useCallback(() => {
commitV2CommandSearchPersistentFilter();
setIsV2CommandSearchOpen(false);
setV2CommandSearchValue('');
setV2CommandActiveIndex(0);
}, [commitV2CommandSearchPersistentFilter]);
useEffect(() => {
setSearchValue(v2PersistedSidebarFilter);
}, [v2PersistedSidebarFilter]);
useEffect(() => {
if (!v2UseLegacySidebarFilter) {
return;
}
const nextFilter = searchValue.trim();
if (nextFilter !== v2PersistedSidebarFilter) {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}
}, [searchValue, setAppearance, v2PersistedSidebarFilter, v2UseLegacySidebarFilter]);
const handleV2CommandSearchValueChange = useCallback((value: string) => {
setV2CommandSearchValue(value);
}, []);
useEffect(() => {
if (!v2CommandSearchPersistentFilterEnabled) {
return;
}
if (!isV2CommandSearchOpen) {
return;
}
const nextFilter = resolveV2CommandSearchPersistentFilter({
commandSearchValue: deferredV2CommandSearchValue,
persistedFilter: v2PersistedSidebarFilter,
enabled: v2CommandSearchPersistentFilterEnabled,
isOpen: isV2CommandSearchOpen,
});
setSearchValue(nextFilter);
const timer = window.setTimeout(() => {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}, 160);
return () => window.clearTimeout(timer);
}, [deferredV2CommandSearchValue, isV2CommandSearchOpen, setAppearance, v2CommandSearchPersistentFilterEnabled, v2PersistedSidebarFilter]);
const toggleV2CommandSearchPersistentFilter = useCallback((enabled: boolean) => {
const nextFilter = enabled ? v2CommandSearchValue.trim() : '';
setSearchValue(nextFilter);
setAppearance({
v2CommandSearchPersistentFilterEnabled: enabled,
v2SidebarPersistedFilter: nextFilter,
});
message.success(
enabled
? t('sidebar.message.sidebar_filter_sync_enabled')
: t('sidebar.message.sidebar_filter_sync_disabled'),
);
}, [setAppearance, v2CommandSearchValue]);
const resetV2SidebarFilter = useCallback(() => {
setSearchValue('');
setAppearance({
v2CommandSearchPersistentFilterEnabled: false,
v2SidebarPersistedFilter: '',
});
message.success(t('sidebar.message.sidebar_filter_reset'));
}, [setAppearance]);
// Virtual Scroll State
const [treeHeight, setTreeHeight] = useState(500);
const [treeViewportWidth, setTreeViewportWidth] = useState(0);
const treeContainerRef = useRef<HTMLDivElement>(null);
const treeRef = useRef<any>(null);
const treeDataRef = useRef<TreeNode[]>([]);
const externalSQLDirectoryTreesRef = useRef<Record<string, ExternalSQLTreeEntry[]>>({});
const findTreeNodeByKeyRef = useRef<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>(() => null);
const expandConnectionFromRailRef = useRef<(connectionId: string) => void>(() => {});
useEffect(() => {
treeDataRef.current = treeData;
}, [treeData]);
useEffect(() => {
if (!treeContainerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
setTreeHeight(entry.contentRect.height);
setTreeViewportWidth(entry.contentRect.width);
}
});
resizeObserver.observe(treeContainerRef.current);
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const handleFocusSidebarSearch = () => {
if (isV2Ui && !v2UseLegacySidebarFilter) {
openV2CommandSearch();
return;
}
const inputEl = searchInputRef.current?.input as HTMLInputElement | undefined;
if (!inputEl) {
return;
}
inputEl.focus();
inputEl.select();
};
window.addEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener);
return () => {
window.removeEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener);
};
}, [isV2Ui, openV2CommandSearch, v2UseLegacySidebarFilter]);
useEffect(() => {
if (!isV2CommandSearchOpen) return;
const timer = window.setTimeout(() => {
const inputEl = commandSearchInputRef.current?.input as HTMLInputElement | undefined;
inputEl?.focus();
inputEl?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [isV2CommandSearchOpen]);
useEffect(() => {
if (!isV2CommandSearchOpen) return;
const handleV2CommandSearchGlobalKeyDown = (event: KeyboardEvent) => {
if (!shouldCloseV2CommandSearchOnGlobalKey({ key: event.key, isOpen: isV2CommandSearchOpen })) {
return;
}
event.preventDefault();
event.stopPropagation();
closeV2CommandSearch();
};
window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true);
}, [closeV2CommandSearch, isV2CommandSearchOpen]);
// Connection Status State: key -> 'loading' | 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, SidebarConnectionState>>({});
const [isTreeDragging, setIsTreeDragging] = useState(false);
// Create Database Modal
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
const [createDbForm] = Form.useForm();
const [targetConnection, setTargetConnection] = useState<any>(null);
const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false);
const [createSchemaForm] = Form.useForm();
const [createSchemaTarget, setCreateSchemaTarget] = useState<any>(null);
const [isRenameSchemaModalOpen, setIsRenameSchemaModalOpen] = useState(false);
const [renameSchemaForm] = Form.useForm();
const [renameSchemaTarget, setRenameSchemaTarget] = useState<any>(null);
const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false);
const [renameDbForm] = Form.useForm();
const [renameDbTarget, setRenameDbTarget] = useState<any>(null);
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
const [renameTableForm] = Form.useForm();
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
const [messagePublishTarget, setMessagePublishTarget] = useState<SidebarMessagePublishTarget | null>(null);
const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false);
const [renameViewForm] = Form.useForm();
const [renameViewTarget, setRenameViewTarget] = useState<any>(null);
const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false);
const [renameSavedQueryForm] = Form.useForm();
const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState<SavedQuery | null>(null);
// Connection Tag Modals
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
const [createTagForm] = Form.useForm();
const {
isBatchModalOpen,
setIsBatchModalOpen,
batchTables,
checkedTableKeys,
setCheckedTableKeys,
selectedConnection,
selectedDatabase,
availableDatabases,
batchFilterKeyword,
setBatchFilterKeyword,
batchFilterType,
setBatchFilterType,
batchSelectionScope,
setBatchSelectionScope,
filteredBatchObjects,
groupedBatchObjects,
selectionScopeTargetKeys,
isBatchDbModalOpen,
setIsBatchDbModalOpen,
batchDatabases,
checkedDbKeys,
setCheckedDbKeys,
selectedDbConnection,
handleExportDatabaseSQL,
handleExportSchemaSQL,
openBatchOperationModal,
openBatchTableExportWorkbench,
handleConnectionChange,
handleDatabaseChange,
handleBatchExport,
handleBatchClear,
handleBatchDeleteTables,
handleCheckAll,
handleInvertSelection,
openBatchDatabaseModal,
openBatchDatabaseExportWorkbench,
handleDbConnectionChange,
handleBatchDbExport,
handleBatchDbDelete,
handleCheckAllDb,
handleInvertSelectionDb,
} = useSidebarBatchExport({
connections,
selectedNodesRef,
addTab,
addSqlLog,
});
// Find in Database Modal
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
useEffect(() => {
if (!autoFetchVisible) {
return;
}
expandedKeys.forEach(key => {
const node = findTreeNodeByKey(treeData, key);
if (node && node.type === 'database') {
loadTables(node);
}
});
}, [autoFetchVisible, savedQueries]);
useEffect(() => {
const previousSignatures = connectionReloadSignaturesRef.current;
const nextSignatures: Record<string, string> = {};
const staleConnectionIds = new Set<string>();
connections.forEach((conn) => {
const signature = buildConnectionReloadSignature(conn);
nextSignatures[conn.id] = signature;
if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) {
staleConnectionIds.add(conn.id);
}
});
connectionReloadSignaturesRef.current = nextSignatures;
if (staleConnectionIds.size > 0) {
const staleIds = Array.from(staleConnectionIds);
setLoadedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setExpandedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setConnectionStates((prev) => {
const next = { ...prev };
staleIds.forEach((id) => {
Object.keys(next).forEach((key) => {
if (isConnectionTreeKey(key, id)) {
delete next[key];
}
});
});
return next;
});
staleIds.forEach((id) => {
Array.from(loadingNodesRef.current).forEach((key) => {
if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) {
loadingNodesRef.current.delete(key);
}
});
});
}
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
// We need to recursively extract connections from old tag structures
// so if a user expands a connection that was tagged, the state remains
const recurseCollect = (nodes: TreeNode[]) => {
nodes.forEach((node) => {
if (node.type === 'tag') {
if (node.children) recurseCollect(node.children);
} else if (node.type === 'connection') {
prevMap.set(String(node.key), node);
}
});
};
recurseCollect(prev);
const buildConnectionNode = (conn: SavedConnection): TreeNode => {
const existing = prevMap.get(conn.id);
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
return {
title: conn.name,
key: conn.id,
icon: getDbIcon(iconType, iconColor, 22),
type: 'connection',
dataRef: conn,
isLeaf: false,
children: preserveChildren ? existing.children : undefined,
} as TreeNode;
};
const taggedConnIds = new Set<string>();
const tagNodesById = new Map<string, TreeNode>();
connectionTags.forEach((tag) => {
tag.connectionIds.forEach(id => taggedConnIds.add(id));
tagNodesById.set(tag.id, {
title: tag.name,
key: `tag-${tag.id}`,
icon: (
<span
className="gn-v2-tree-folder-icon"
data-sidebar-tree-folder-icon="true"
>
<FolderOutlined />
</span>
),
type: 'tag',
dataRef: tag,
isLeaf: false,
children: tag.connectionIds
.map(cid => connections.find(c => c.id === cid))
.filter(Boolean)
.map(conn => buildConnectionNode(conn!)),
} as TreeNode);
});
const ungroupedNodesById = new Map<string, TreeNode>();
connections
.filter(c => !taggedConnIds.has(c.id))
.forEach((conn) => {
ungroupedNodesById.set(conn.id, buildConnectionNode(conn));
});
const orderedRootTokens = resolveSidebarRootOrderTokens(
sidebarRootOrder,
connectionTags,
connections,
);
const orderedNodes: TreeNode[] = [];
orderedRootTokens.forEach((token) => {
if (token.startsWith('tag:')) {
const tagNode = tagNodesById.get(token.slice('tag:'.length));
if (!tagNode) return;
orderedNodes.push(tagNode);
tagNodesById.delete(token.slice('tag:'.length));
return;
}
if (token.startsWith('connection:')) {
const connectionNode = ungroupedNodesById.get(token.slice('connection:'.length));
if (!connectionNode) return;
orderedNodes.push(connectionNode);
ungroupedNodesById.delete(token.slice('connection:'.length));
}
});
orderedNodes.push(...Array.from(tagNodesById.values()));
orderedNodes.push(...Array.from(ungroupedNodesById.values()));
if (allSavedQueriesNode) {
orderedNodes.push(allSavedQueriesNode);
}
const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root');
return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes;
});
}, [connections, connectionTags, sidebarRootOrder, allSavedQueriesNode]);
const handleDuplicateConnection = async (conn: SavedConnection) => {
if (!conn?.id) return;
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DuplicateConnection !== 'function') {
message.error(t('connection.sidebar.duplicate.backendUnavailable'));
return;
}
try {
const duplicatedConnection = await backendApp.DuplicateConnection(conn.id);
if (!duplicatedConnection) {
throw new Error(t('connection.sidebar.duplicate.noResult'));
}
addConnection(duplicatedConnection);
message.success(t('connection.sidebar.duplicate.success', {
name: duplicatedConnection.name,
}));
} catch (error: any) {
message.error(error?.message || t('connection.sidebar.duplicate.failureFallback'));
}
};
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
return list.map(node => {
if (node.key === key) {
return { ...node, children };
}
if (node.children) {
return { ...node, children: updateTreeData(node.children, key, children) };
}
return node;
});
};
const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
for (const node of nodes) {
if (node.key === targetKey) {
return node;
}
if (node.children) {
const child = findTreeNodeByKey(node.children, targetKey);
if (child) {
return child;
}
}
}
return null;
};
findTreeNodeByKeyRef.current = findTreeNodeByKey;
const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
const nextTreeData = updateTreeData(treeDataRef.current, key, children);
treeDataRef.current = nextTreeData;
setTreeData(nextTreeData);
return nextTreeData;
};
const clearTreeNodeChildrenByKeys = useCallback((keysToClear: string[]) => {
const keysToClearSet = new Set(keysToClear.map((key) => String(key || '').trim()).filter(Boolean));
if (keysToClearSet.size === 0) {
return;
}
const clearChildren = (nodes: TreeNode[]): TreeNode[] => (
nodes.map((node) => {
const nodeKey = String(node.key || '').trim();
if (keysToClearSet.has(nodeKey)) {
return { ...node, children: undefined };
}
if (node.children?.length) {
return { ...node, children: clearChildren(node.children) };
}
return node;
})
);
setTreeData((prev) => {
const nextTreeData = clearChildren(prev);
treeDataRef.current = nextTreeData;
return nextTreeData;
});
setLoadedKeys((prev) => prev.filter((key) => !keysToClearSet.has(String(key))));
keysToClearSet.forEach((key) => {
delete databaseTreeTouchedAtRef.current[key];
});
}, []);
const pruneLoadedDatabaseTrees = useCallback(() => {
const activeDatabaseKey = activeContext?.connectionId && activeContext?.dbName
? `${activeContext.connectionId}-${activeContext.dbName}`
: '';
const keysToClear = resolveSidebarDatabaseTreePruneKeys({
treeData: treeDataRef.current,
expandedKeys,
selectedKeys,
activeDatabaseKey,
touchedAtByDatabaseKey: databaseTreeTouchedAtRef.current,
maxLoadedDatabases: SIDEBAR_CACHED_DATABASE_TREE_LIMIT,
});
if (keysToClear.length === 0) {
return;
}
clearTreeNodeChildrenByKeys(keysToClear);
}, [activeContext?.connectionId, activeContext?.dbName, clearTreeNodeChildrenByKeys, expandedKeys, selectedKeys]);
pruneLoadedDatabaseTreesRef.current = pruneLoadedDatabaseTrees;
const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => {
setExpandedKeys(prev => {
const merged = [...prev];
requiredKeys.forEach(key => {
if (!merged.includes(key)) merged.push(key);
});
return merged;
});
setAutoExpandParent(true);
};
const scrollSidebarTreeToKey = (key: React.Key) => {
const runAfterFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame.bind(window)
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0);
runAfterFrame(() => {
treeRef.current?.scrollTo?.({ key, align: 'auto' });
runAfterFrame(() => {
const selectedNode = treeContainerRef.current?.querySelector('.ant-tree-treenode-selected') as HTMLElement | null;
selectedNode?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
});
});
};
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
const icon = (() => {
switch (node.type) {
case 'external-sql-root':
return <FolderOpenOutlined />;
case 'external-sql-directory':
return <HddOutlined />;
case 'external-sql-folder':
return <FolderOutlined />;
default:
return <FileTextOutlined />;
}
})();
return {
...node,
icon,
children: node.children?.map((child) => decorateExternalSQLTreeNode(child)),
};
};
const buildExternalSQLRootTreeNode = useCallback((
directories: ExternalSQLDirectory[] = externalSQLDirectories,
directoryTrees: Record<string, ExternalSQLTreeEntry[]> = externalSQLDirectoryTreesRef.current,
): TreeNode => decorateExternalSQLTreeNode(buildExternalSQLRootNode({
directories,
directoryTrees,
})), [externalSQLDirectories]);
const refreshGlobalExternalSQLRootNode = useCallback(async (
showSuccess = false,
directoriesOverride?: ExternalSQLDirectory[],
) => {
const targetDirectories = directoriesOverride || externalSQLDirectories;
const directoryTrees: Record<string, ExternalSQLTreeEntry[]> = {};
await Promise.all(targetDirectories.map(async (directory) => {
const directoryRes = await ListSQLDirectory(directory.path);
if (!directoryRes.success) {
message.warning({
key: `external-sql-${directory.id}`,
content: t('sidebar.message.external_sql_directory_read_failed', {
name: directory.name,
error: directoryRes.message,
}),
});
directoryTrees[directory.id] = [];
return;
}
directoryTrees[directory.id] = Array.isArray(directoryRes.data)
? directoryRes.data as ExternalSQLTreeEntry[]
: [];
}));
externalSQLDirectoryTreesRef.current = directoryTrees;
const rootNode = buildExternalSQLRootTreeNode(targetDirectories, directoryTrees);
setTreeData((prev) => {
const withoutExternalRoot = prev.filter((node) => node.type !== 'external-sql-root');
const nextTreeData = [...withoutExternalRoot, rootNode];
treeDataRef.current = nextTreeData;
return nextTreeData;
});
if (showSuccess) {
message.success(t('sidebar.message.external_sql_directory_refreshed'));
}
}, [buildExternalSQLRootTreeNode, externalSQLDirectories]);
useEffect(() => {
void refreshGlobalExternalSQLRootNode(false);
}, [refreshGlobalExternalSQLRootNode]);
const {
handleRunSQLFile,
handleOpenSQLFileFromToolbar,
openExternalSQLFile,
openCreateExternalSQLFileModal,
openRenameExternalSQLFileModal,
openCreateExternalSQLDirectoryModal,
openRenameExternalSQLDirectoryModal,
handleDeleteExternalSQLFile,
handleDeleteExternalSQLDirectory,
handleAddExternalSQLDirectory,
handleRemoveExternalSQLDirectory,
handleRefreshExternalSQLDirectory,
externalSQLFileModalProps,
} = useSidebarExternalSqlWorkflow({
connections,
externalSQLDirectories,
activeTab,
connectionIds,
selectedNodesRef,
addTab,
saveExternalSQLDirectory,
deleteExternalSQLDirectory,
refreshGlobalExternalSQLRootNode,
setExpandedKeys,
setAutoExpandParent,
getActiveContext: () => useStore.getState().activeContext,
});
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
if (!node) return null;
if (node.type === 'database') {
return {
connectionId: String(node?.dataRef?.id || '').trim(),
dbName: String(node?.dataRef?.dbName || '').trim(),
dbNodeKey: String(node.key || '').trim(),
};
}
if (
node.type === 'external-sql-root'
|| node.type === 'external-sql-directory'
|| node.type === 'external-sql-folder'
|| node.type === 'external-sql-file'
) {
return {
connectionId: String(node?.dataRef?.connectionId || '').trim(),
dbName: String(node?.dataRef?.dbName || '').trim(),
dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
};
}
return null;
};
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
const waitForSidebarLoadKey = async (loadKey: string): Promise<boolean> => {
for (let attempt = 0; attempt < SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS && loadingNodesRef.current.has(loadKey); attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS));
}
return !loadingNodesRef.current.has(loadKey);
};
const locateObjectInSidebar = async (detail: unknown) => {
const request = normalizeSidebarLocateObjectRequest(detail);
if (!request) {
message.warning(t('sidebar.message.locate_current_table_unavailable'));
return;
}
if (request.objectGroup === 'externalSqlFiles') {
await refreshGlobalExternalSQLRootNode(false);
const target = resolveSidebarLocateTarget(request, { groupBySchema: false });
const path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
if (!path) {
message.warning(t('sidebar.message.locate_external_sql_file_not_found', { path: request.filePath }));
return;
}
const targetKey = path[path.length - 1];
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
setSearchValue('');
mergeExpandedTreeKeys(path.slice(0, -1));
setSelectedKeys([targetKey]);
selectedNodesRef.current = targetNode ? [targetNode] : [];
const connectionId = String(request.connectionId || activeContext?.connectionId || activeTab?.connectionId || '').trim();
const dbName = String(request.dbName || activeContext?.dbName || activeTab?.dbName || '').trim();
if (connectionId) {
setActiveContext({ connectionId, dbName });
}
scrollSidebarTreeToKey(targetKey);
return;
}
const conn = connections.find(item => item.id === request.connectionId);
if (!conn) {
message.warning(t('sidebar.message.locate_connection_not_found_for_object'));
return;
}
const target = resolveSidebarLocateTarget(request, {
groupBySchema: shouldHideSchemaPrefix(conn),
});
const objectLabel = request.objectGroup === 'materializedViews'
? t('sidebar.locate.object.materialized_view')
: request.objectGroup === 'views'
? t('sidebar.locate.object.view')
: request.objectGroup === 'triggers'
? t('sidebar.locate.object.trigger')
: request.objectGroup === 'routines'
? t('sidebar.locate.object.routine')
: t('sidebar.locate.object.table');
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
const dbLoadKey = `dbs-${request.connectionId}`;
const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`;
if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) {
const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey);
if (!connectionNode) {
message.warning(t('sidebar.message.locate_connection_not_in_tree'));
return;
}
if (loadingNodesRef.current.has(dbLoadKey)) {
const loaded = await waitForSidebarLoadKey(dbLoadKey);
if (!loaded) {
message.info(t('sidebar.message.locate_database_loading', { database: request.dbName }));
return;
}
} else {
await loadDatabases(connectionNode);
}
}
const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey);
if (!dbNode) {
message.warning(t('sidebar.message.locate_database_not_found', { database: request.dbName }));
return;
}
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
if (!path) {
if (loadingNodesRef.current.has(tableLoadKey)) {
const loaded = await waitForSidebarLoadKey(tableLoadKey);
if (!loaded) {
message.info(t('sidebar.message.locate_object_loading', {
object: objectLabel,
database: request.dbName,
}));
return;
}
} else {
await loadTables(dbNode);
}
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
}
if (!path) {
message.warning(t('sidebar.message.locate_object_not_found', {
object: objectLabel,
name: request.tableName,
}));
return;
}
const targetKey = path[path.length - 1];
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
setSearchValue('');
mergeExpandedTreeKeys(path.slice(0, -1));
setSelectedKeys([targetKey]);
selectedNodesRef.current = targetNode ? [targetNode] : [];
setActiveContext({ connectionId: request.connectionId, dbName: request.dbName });
scrollSidebarTreeToKey(targetKey);
};
const handleLocateActiveTabInSidebar = () => {
if (!activeTabLocateRequest) {
message.warning(t('sidebar.message.locate_current_table_unavailable'));
return;
}
void locateObjectInSidebar(activeTabLocateRequest);
};
useEffect(() => {
locateObjectInSidebarRef.current = locateObjectInSidebar;
});
useEffect(() => {
const handleLocateSidebarObject = (event: Event) => {
void locateObjectInSidebarRef.current((event as CustomEvent).detail);
};
window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
return () => {
window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
};
}, []);
useEffect(() => {
const handleSidebarTablePinChanged = (event: Event) => {
const detail = (event as CustomEvent).detail || {};
const connectionId = String(detail.connectionId || '').trim();
const dbName = String(detail.dbName || '').trim();
if (!connectionId || !dbName) return;
const dbNode = findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`);
if (dbNode) {
void loadTables(dbNode);
}
};
window.addEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener);
return () => {
window.removeEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener);
};
}, []);
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'unmatched-saved-queries') return;
if (hasSidebarLazyChildren(children)) return;
if (type === 'connection') {
await loadDatabases({ key, dataRef });
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
await loadJVMResources({ key, dataRef });
} else if (type === 'database') {
await loadTables({ key, dataRef });
} else if (type === 'external-sql-root') {
await refreshGlobalExternalSQLRootNode(false);
} else if (type === 'table') {
// Expand table to show object categories
const conn = dataRef;
const folders: TreeNode[] = [
{
title: t('sidebar.table_folder.columns'),
key: `${key}-columns`,
icon: <UnorderedListOutlined />,
type: 'folder-columns',
isLeaf: true,
dataRef: conn
},
{
title: t('sidebar.table_folder.indexes'),
key: `${key}-indexes`,
icon: <KeyOutlined style={{ transform: 'rotate(45deg)' }} />,
type: 'folder-indexes',
isLeaf: true,
dataRef: conn
},
{
title: t('sidebar.table_folder.foreign_keys'),
key: `${key}-fks`,
icon: <LinkOutlined />,
type: 'folder-fks',
isLeaf: true,
dataRef: conn
},
{
title: t('sidebar.table_folder.triggers'),
key: `${key}-triggers`,
icon: <ThunderboltOutlined />,
type: 'folder-triggers',
isLeaf: true,
dataRef: conn
}
];
replaceTreeNodeChildren(key, folders);
}
};
const isStructureOnlyDbType = (connectionId: string): boolean => {
const conn = connections.find(c => c.id === connectionId);
if (!conn) return false;
const dbType = resolveDataSourceType(conn.config);
return dbType === 'elasticsearch' || dbType === 'mongodb' || dbType === 'redis' || dbType === 'iotdb';
};
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
const { tableName, dbName, id } = node.dataRef;
const conn = connections.find(c => c.id === id);
const forceReadOnly = readOnly
|| isStructureOnlyDbType(id)
|| isConnectionStructureEditRestricted(conn?.config);
addTab({
id: `design-${id}-${dbName}-${tableName}`,
title: forceReadOnly
? t('sidebar.tab.table_structure', { table: tableName })
: t('sidebar.tab.design_table', { table: tableName }),
type: 'design',
connectionId: id,
dbName: dbName,
tableName: tableName,
initialTab: initialTab,
readOnly: forceReadOnly
});
};
const openNewTableDesign = (node: any) => {
const { dbName, id } = node.dataRef;
const conn = connections.find(c => c.id === id);
if (isStructureOnlyDbType(id) || isConnectionStructureEditRestricted(conn?.config)) {
message.warning(t('sidebar.message.visual_new_table_unsupported'));
return;
}
addTab({
id: `new-table-${id}-${dbName}-${Date.now()}`,
title: t('sidebar.tab.new_table', { database: dbName }),
type: 'design',
connectionId: id,
dbName: dbName,
tableName: '', // Empty tableName signals creation mode
initialTab: 'columns',
readOnly: false
});
};
const openSidebarObjectNode = (node: any): boolean => {
if (node.type === 'view' || node.type === 'materialized-view') {
const { viewName, dbName, id, schemaName } = node.dataRef;
addTab({
id: node.key,
title: viewName,
type: 'table',
connectionId: id,
dbName,
tableName: viewName,
objectType: node.type === 'materialized-view' ? 'materialized-view' : 'view',
schemaName,
sidebarLocateKey: String(node.key || ''),
});
return true;
}
if (node.type === 'db-trigger') {
const { triggerName, triggerTableName, schemaName, dbName, id } = node.dataRef;
addTab({
id: `trigger-${node.key}`,
title: t('sidebar.tab.trigger', { name: triggerName }),
type: 'trigger',
connectionId: id,
dbName,
triggerName,
triggerTableName,
schemaName,
sidebarLocateKey: String(node.key || ''),
});
return true;
}
if (node.type === 'db-event') {
openEventDefinition(node);
return true;
}
if (node.type === 'routine') {
const { routineName, routineType, dbName, id } = node.dataRef;
const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function');
addTab({
id: `routine-def-${node.key}`,
title: t('sidebar.tab.routine_definition', { type: typeLabel, name: routineName }),
type: 'routine-def',
connectionId: id,
dbName,
routineName,
routineType
});
return true;
}
if (node.type === 'sequence') {
openSequenceDefinition(node);
return true;
}
if (node.type === 'package') {
openPackageDefinition(node);
return true;
}
return false;
};
const onSelect = (keys: React.Key[], info: any) => {
if (isV2Ui && info?.node?.type === 'v2-table-section') {
return;
}
if (Date.now() < treeDragSelectSuppressUntilRef.current) {
return;
}
if (isTreeDragging) {
return;
}
setSelectedKeys(keys);
selectedNodesRef.current = info.selectedNodes || [];
if (keys.length === 0) {
if (shouldClearSidebarActiveContextOnEmptySelect(isV2Ui)) {
setActiveContext(null);
}
return;
}
if (shouldSkipSidebarSelectWhileDragging(isTreeDragging, info)) return;
const { type, dataRef, key, title } = info.node;
const nodeConnectionId = resolveSidebarNodeConnectionId(info.node, connectionIds);
// Update active context
if (type === 'connection') {
setActiveContext({ connectionId: key, dbName: '' });
} else if (type === 'database') {
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
} else if (type === 'table') {
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: '' });
} else if (type === 'view' || type === 'materialized-view' || type === 'sequence' || type === 'package' || type === 'db-trigger' || type === 'db-event' || type === 'routine') {
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
if (type === 'folder-columns') openDesign(info.node, 'columns', false);
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
else if (type === 'object-group' && dataRef?.groupKey === 'tables') {
// 单击延迟打开表概览,双击时会取消此定时器
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
const { id, dbName: gDbName, schemaName } = dataRef;
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
addTab({
id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`,
title: t('sidebar.tab.table_overview', {
database: gDbName,
schema: schemaName ? ` (${schemaName})` : '',
}),
type: 'table-overview' as any,
connectionId: id,
dbName: gDbName,
schemaName,
} as any);
}, 250);
} else if (openSidebarObjectNode(info.node)) {
return;
}
};
const onExpand = (newExpandedKeys: React.Key[], info?: any) => {
if (!info?.expanded && shouldClearSidebarNodeChildrenOnCollapse(info?.node)) {
const collapsedKey = String(info.node?.key || '').trim();
const keysToClear = [
collapsedKey,
...collectSidebarSubtreeKeys(info.node),
].filter(Boolean);
const keysToClearSet = new Set(keysToClear);
setExpandedKeys(newExpandedKeys.filter((key) => !keysToClearSet.has(String(key))));
setAutoExpandParent(false);
clearTreeNodeChildrenByKeys(keysToClear);
return;
}
setExpandedKeys(newExpandedKeys);
setAutoExpandParent(false);
if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info)) {
void onLoadData(info.node);
}
};
const onDoubleClick = (e: any, node: any) => {
// 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
const { type, dataRef, key: nodeKey } = node;
if (isV2Ui && type === 'v2-table-section') {
return;
}
const nodeConnectionId = resolveSidebarNodeConnectionId(node, connectionIds);
if (type === 'connection') {
setSelectedKeys([nodeKey]);
selectedNodesRef.current = [node];
setActiveContext({ connectionId: nodeKey, dbName: '' });
} else if (type === 'database') {
setSelectedKeys([nodeKey]);
selectedNodesRef.current = [node];
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: '' });
} else if (type === 'table' || type === 'view' || type === 'materialized-view' || type === 'sequence' || type === 'package' || type === 'db-trigger' || type === 'db-event' || type === 'routine') {
setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问
recordTableAccess(id, dbName, tableName);
addTab({
id: node.key,
title: tableName,
type: 'table',
connectionId: id,
dbName,
tableName,
initialViewMode: tableDoubleClickAction === 'open-design' ? 'fields' : undefined,
initialViewModeRequestId: tableDoubleClickAction === 'open-design' ? String(Date.now()) : undefined,
objectType: 'table',
});
return;
} else if (node.type === 'view' || node.type === 'materialized-view') {
const { viewName, dbName, id, schemaName } = node.dataRef;
addTab({
id: node.key,
title: viewName,
type: 'table',
connectionId: id,
dbName,
tableName: viewName,
objectType: node.type === 'materialized-view' ? 'materialized-view' : 'view',
schemaName,
sidebarLocateKey: String(node.key || ''),
});
return;
} else if (node.type === 'saved-query') {
const q = node.dataRef;
addTab({
id: q.id,
title: resolveSavedQueryDisplayName(q.name),
type: 'query',
connectionId: q.connectionId,
dbName: q.dbName,
query: q.sql,
savedQueryId: q.id,
});
return;
} else if (node.type === 'external-sql-file') {
void openExternalSQLFile(node);
return;
} else if (node.type === 'redis-db') {
const { id, redisDB } = node.dataRef;
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
return;
} else if (node.type === 'db-trigger') {
const { triggerName, triggerTableName, schemaName, dbName, id } = node.dataRef;
addTab({
id: `trigger-${node.key}`,
title: t('sidebar.tab.trigger', { name: triggerName }),
type: 'trigger',
connectionId: id,
dbName,
triggerName,
triggerTableName,
schemaName,
sidebarLocateKey: String(node.key || ''),
});
return;
} else if (node.type === 'db-event') {
openEventDefinition(node);
return;
} else if (node.type === 'routine') {
const { routineName, routineType, dbName, id } = node.dataRef;
const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function');
addTab({
id: `routine-def-${node.key}`,
title: t('sidebar.tab.routine_definition', { type: typeLabel, name: routineName }),
type: 'routine-def',
connectionId: id,
dbName,
routineName,
routineType
});
return;
} else if (node.type === 'sequence') {
openSequenceDefinition(node);
return;
} else if (node.type === 'package') {
openPackageDefinition(node);
return;
} else if (node.type === 'jvm-mode') {
const { providerMode, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMOverviewTab(conn, providerMode);
return;
} else if (node.type === 'jvm-resource') {
const { providerMode, resourcePath, resourceKind, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMResourceTab(conn, providerMode, resourcePath, resourceKind);
return;
} else if (node.type === 'jvm-monitoring') {
const { providerMode, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMMonitoringTab(conn, providerMode);
return;
} else if (node.type === 'jvm-diagnostic') {
const conn = (connections.find((item) => item.id === node.dataRef.id) || node.dataRef) as SavedConnection;
openJVMDiagnosticTab(conn);
return;
}
const key = node.key;
const isExpanded = expandedKeys.includes(key);
const newExpandedKeys = isExpanded
? expandedKeys.filter(k => k !== key)
: [...expandedKeys, key];
setExpandedKeys(newExpandedKeys);
if (!isExpanded) {
setAutoExpandParent(false);
if (shouldLoadSidebarNodeOnExpand(node)) {
void onLoadData(node);
}
}
};
const renderSidebarSwitcherIcon = useCallback((node: SidebarTreeSwitcherNodeLike) => {
if (node.isLeaf) {
return null;
}
const keepCollapsed = shouldKeepSidebarSwitcherCollapsedWhileLoading(node, loadingNodesRef.current);
return <CaretDownFilled rotate={keepCollapsed ? -90 : undefined} />;
}, []);
const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {
return buildRpcConnectionConfig(conn.config, {
database: resolveSidebarRuntimeDatabase(
conn?.config?.type,
conn?.config?.driver,
conn?.config?.database,
overrideDatabase,
clearDatabase,
conn?.config?.oceanBaseProtocol,
),
});
};
const buildJVMRuntimeConfig = (conn: SavedConnection & { dbName?: string }, providerMode: string) => {
const sourceJVM = conn.config.jvm || {};
return buildRpcConnectionConfig(conn.config, {
database: '',
jvm: {
...sourceJVM,
preferredMode: providerMode as 'jmx' | 'endpoint' | 'agent',
allowedModes: [providerMode as 'jmx' | 'endpoint' | 'agent'],
},
});
};
const openJVMOverviewTab = (conn: SavedConnection, providerMode: string) => {
addTab({
id: `jvm-overview-${conn.id}-${providerMode}`,
title: buildJVMTabTitle(conn.name, 'overview', providerMode),
type: 'jvm-overview',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
});
};
const openJVMMonitoringTab = (conn: SavedConnection, providerMode: string) => {
addTab({
id: `jvm-monitoring-${conn.id}-${providerMode}`,
title: buildJVMTabTitle(conn.name, 'monitoring', providerMode),
type: 'jvm-monitoring',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
});
};
const buildJVMDiagnosticTreeNodes = (conn: SavedConnection): TreeNode[] => {
const descriptor = buildJVMDiagnosticActionDescriptor(conn.id, conn.config.jvm?.diagnostic, t);
if (!descriptor) {
return [];
}
return [{
title: descriptor.title,
key: descriptor.key,
icon: <DashboardOutlined />,
type: 'jvm-diagnostic',
dataRef: {
...conn,
diagnosticTransport: descriptor.transport,
},
isLeaf: true,
}];
};
const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => {
const trimmedResourcePath = String(resourcePath || '').trim();
addTab({
id: `jvm-resource-${conn.id}-${providerMode}-${encodeURIComponent(trimmedResourcePath)}`,
title: trimmedResourcePath
? `${buildJVMTabTitle(conn.name, 'resource', providerMode)} · ${trimmedResourcePath}`
: buildJVMTabTitle(conn.name, 'resource', providerMode),
type: 'jvm-resource',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
resourcePath: trimmedResourcePath,
resourceKind,
});
};
const openJVMDiagnosticTab = (conn: SavedConnection) => {
const transport = conn.config.jvm?.diagnostic?.transport || 'agent-bridge';
addTab({
id: `jvm-diagnostic-${conn.id}`,
title: buildJVMTabTitle(conn.name, 'diagnostic', transport),
type: 'jvm-diagnostic',
connectionId: conn.id,
});
};
const getConnectionNodeRef = (connRef: any) => {
const latestConn = connections.find(c => c.id === connRef.id);
return { key: connRef.id, dataRef: latestConn || connRef };
};
const getDatabaseNodeRef = (connRef: any, dbName: string) => {
const latestConn = connections.find(c => c.id === connRef.id);
return {
key: `${connRef.id}-${dbName}`,
dataRef: { ...(latestConn || connRef), dbName }
};
};
const extractObjectName = (fullName: string) => {
return splitQualifiedName(String(fullName || '').trim()).objectName || String(fullName || '').trim();
};
const resolveSavedQueryDisplayName = (name: string | null | undefined) => {
const rawName = String(name || '').trim();
return rawName || t('query_editor.save_modal.unnamed');
};
const {
loadDatabases,
loadJVMResources,
loadTables,
} = useSidebarTreeLoaders({
savedQueries,
tableSortPreference,
tableAccessCount,
pinnedSidebarTables,
isV2Ui,
loadingNodesRef,
setConnectionStates,
setLoadedKeys,
replaceTreeNodeChildren,
buildRuntimeConfig,
buildJVMRuntimeConfig,
buildJVMDiagnosticTreeNodes,
resolveSavedQueryDisplayName,
onDatabaseTreeLoaded: (databaseKey: string) => {
databaseTreeTouchedAtRef.current[databaseKey] = Date.now();
pruneLoadedDatabaseTrees();
},
});
const {
handleCopyStructure,
handleCopyTableName,
handleExport,
openExportDialog,
handleCopyTableAsInsert,
openTableDdlInDesigner,
openTableInERView,
injectTablePromptToAI,
handleCreateDatabase,
openCreateSchemaModal,
handleCreateSchema,
openRenameSchemaModal,
handleRenameSchema,
handleDeleteSchema,
handleRenameDatabase,
handleDeleteDatabase,
handleRenameTable,
handleDeleteTable,
handleTableDataDangerAction,
openViewDefinition,
openEditView,
openCreateView,
openCreateStarRocksMaterializedView,
openCreateStarRocksExternalCatalog,
openCreateStarRocksRollup,
handleDropView,
handleRenameView,
openRenameSavedQueryModal,
handleRenameSavedQuery,
isSavedQueryUnmatched,
handleRebindSavedQuery,
openRoutineDefinition,
openEventDefinition,
openEditEvent,
openSequenceDefinition,
openPackageDefinition,
openEditRoutine,
openCreateRoutine,
handleDropRoutine,
resolveMessagePublishTarget,
openMessagePublishModal,
handleMessagePublishSuccess,
} = useSidebarObjectActions({
connections,
connectionIds,
connectionIdSet,
tabs,
treeDataRef,
setTreeData,
setExpandedKeys,
setLoadedKeys,
addTab,
updateQueryTabDraft,
saveQuery,
addSqlLog,
closeTabsByDatabase,
createDbForm,
targetConnection,
setIsCreateDbModalOpen,
createSchemaForm,
createSchemaTarget,
setCreateSchemaTarget,
setIsCreateSchemaModalOpen,
renameSchemaForm,
renameSchemaTarget,
setRenameSchemaTarget,
setIsRenameSchemaModalOpen,
renameDbForm,
renameDbTarget,
setRenameDbTarget,
setIsRenameDbModalOpen,
renameTableForm,
renameTableTarget,
setRenameTableTarget,
setIsRenameTableModalOpen,
renameViewForm,
renameViewTarget,
setRenameViewTarget,
setIsRenameViewModalOpen,
renameSavedQueryForm,
renameSavedQueryTarget,
setRenameSavedQueryTarget,
setIsRenameSavedQueryModalOpen,
setMessagePublishTarget,
buildRuntimeConfig,
getConnectionNodeRef,
getDatabaseNodeRef,
extractObjectName,
isPostgresSchemaDialect,
loadDatabases,
loadTables,
openDesign,
onDoubleClick,
runExportWithProgress,
setAIPanelVisible,
addAIContext,
});
const refreshV2TableContextMenuStatsRef = useRef<(node: any) => void>(() => {});
const {
getConnectionNodeForAction,
toggleSidebarTablePinned,
handleV2TableContextMenuAction,
handleTableGroupSortAction,
handleV2TableGroupContextMenuAction,
handleV2DatabaseContextMenuAction,
disconnectConnectionNode,
deleteConnectionNode,
handleV2ConnectionContextMenuAction,
handleV2ConnectionGroupContextMenuAction,
} = useSidebarV2ActionHandlers({
connections,
connectionTags,
pinnedSidebarTables,
loadingNodesRef,
treeDataRef,
findTreeNodeByKeyRef,
refreshV2TableContextMenuStatsRef,
setConnectionStates,
setExpandedKeys,
setLoadedKeys,
setTargetConnection,
setIsCreateDbModalOpen,
setRenameDbTarget,
setIsRenameDbModalOpen,
setRenameTableTarget,
setIsRenameTableModalOpen,
setRenameViewTarget,
setIsCreateTagModalOpen,
renameDbForm,
renameTableForm,
createTagForm,
addTab,
closeTabsByDatabase,
closeTabsByConnection,
removeConnection,
removeConnectionTag,
moveConnectionToTag,
setSidebarTablePinned,
setTableSortPreference,
replaceTreeNodeChildren,
loadDatabases,
loadTables,
getDatabaseNodeRef,
extractObjectName,
openDesign,
openNewTableDesign,
onDoubleClick,
openMessagePublishModal,
openTableDdlInDesigner,
openTableInERView,
handleCopyTableName,
handleCopyStructure,
handleCopyTableAsInsert,
openCreateStarRocksRollup,
handleExport,
openExportDialog,
injectTablePromptToAI,
handleTableDataDangerAction,
handleDeleteTable,
openCreateSchemaModal,
openCreateStarRocksMaterializedView,
openCreateStarRocksExternalCatalog,
handleExportDatabaseSQL,
handleRunSQLFile,
handleDeleteDatabase,
onEditConnection,
handleDuplicateConnection,
buildConnectionRootQueryTabTitle,
buildConnectionRootRedisCommandTabTitle,
buildConnectionRootRedisMonitorTabTitle,
});
const {
onSearch,
searchScopeSummary,
searchScopePopoverContent,
displayTreeData,
v2CommandSearchObjectMode,
v2CommandSearchAiMode,
filteredCommandSearchTreeItems,
filteredCommandSearchActionItems,
filteredCommandSearchRecentItems,
commandSearchAiItem,
commandSearchFlatItems,
flattenConnectionNodes,
activeConnection,
activeConnectionDisplayName,
activeDatabaseDisplayName,
v2VisibleTreeData,
v2TreeHorizontalScrollWidth,
effectiveTreeHeight,
v2TreeMetrics,
activeConnectionObjectCount,
} = useSidebarSearchModel({
searchScopes,
setSearchScopes,
setSearchValue,
deferredSearchValue,
deferredV2CommandSearchValue,
v2CommandSearchValue,
setV2CommandActiveIndex,
v2ExplorerFilter,
sidebarTableMetadataFields,
treeData,
treeViewportWidth,
treeHeight,
isV2Ui,
isV2CommandSearchOpen,
connections,
connectionIds,
selectedKeys,
selectedNodesRef,
activeContext,
activeTab,
sqlLogs,
shortcutOptions,
activeShortcutPlatform,
overlayTheme,
darkMode,
onCreateConnection,
onToggleAI,
onToggleLogPanel,
setAIPanelVisible,
extractObjectName,
});
const legacyToolbarButtonColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
const legacyToolbarStyle: React.CSSProperties = {
padding: '6px 16px',
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
gap: 8,
alignItems: 'center',
justifyItems: 'center',
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`,
borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`,
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)',
};
const legacyToolbarItemStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 0,
};
const legacyToolbarDisabledWrapStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
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,
});
refreshV2TableContextMenuStatsRef.current = refreshV2TableContextMenuStats;
const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({
node,
hoverTitle,
statusBadge,
getV2TreeMetaText,
sidebarTableMetadataFields,
toggleSidebarTablePinned,
snapshotTreeSelectionBeforeDrag,
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
});
const {
selectConnectionFromRail,
runCommandSearchItem,
handleV2CommandSearchKeyDown,
} = useSidebarCommandSearchRunner({
activeContext,
activeTab,
addTab,
closeV2CommandSearch,
commandSearchFlatItems,
connectionIds,
findTreeNodeByKeyRef,
locateObjectInSidebar,
loadDatabases,
mergeExpandedTreeKeys,
onDoubleClick,
scrollSidebarTreeToKey,
selectedNodesRef,
setActiveContext,
setSelectedKeys,
setV2CommandActiveIndex,
treeDataRef,
v2CommandActiveIndex,
});
expandConnectionFromRailRef.current = (connectionId: string) => {
const conn = connections.find((item) => item.id === connectionId);
if (conn) {
selectConnectionFromRail(conn);
}
};
const getNodeMenuItems = (node: any): MenuProps['items'] => buildSidebarLegacyNodeMenuItems(node, {
addTab,
getMetadataDialect,
handleV2DatabaseContextMenuAction,
isPostgresSchemaDialect,
handleExportSchemaSQL,
openRenameSchemaModal,
loadTables,
getDatabaseNodeRef,
handleDeleteSchema,
tableSortPreference,
isStructureOnlyDbType,
openNewTableDesign,
handleTableGroupSortAction,
openCreateView,
openCreateStarRocksMaterializedView,
openCreateRoutine,
createTagForm,
setRenameViewTarget,
setIsCreateTagModalOpen,
removeConnectionTag,
setExpandedKeys,
setLoadedKeys,
loadingNodesRef,
loadDatabases,
buildConnectionRootRedisCommandTabTitle,
buildConnectionRootRedisMonitorTabTitle,
onEditConnection,
handleDuplicateConnection,
disconnectConnectionNode,
deleteConnectionNode,
connectionTags,
moveConnectionToTag,
setTargetConnection,
setIsCreateDbModalOpen,
buildConnectionRootQueryTabTitle,
handleRunSQLFile,
openCreateStarRocksExternalCatalog,
openEditView,
renameViewForm,
setIsRenameViewModalOpen,
handleDropView,
onDoubleClick,
openViewDefinition,
openRoutineDefinition,
openEditRoutine,
handleDropRoutine,
openEventDefinition,
openEditEvent,
openSequenceDefinition,
openPackageDefinition,
resolveMessagePublishTarget,
openMessagePublishModal,
openDesign,
openCreateStarRocksRollup,
handleCopyTableName,
handleCopyStructure,
handleExport,
setRenameTableTarget,
renameTableForm,
setIsRenameTableModalOpen,
handleTableDataDangerAction,
handleDeleteTable,
openExportDialog,
isSavedQueryUnmatched,
connections,
handleRebindSavedQuery,
openRenameSavedQueryModal,
resolveSavedQueryDisplayName,
deleteQuery,
treeDataRef,
setTreeData,
handleAddExternalSQLDirectory,
openCreateExternalSQLFileModal,
openCreateExternalSQLDirectoryModal,
openRenameExternalSQLDirectoryModal,
handleRefreshExternalSQLDirectory,
handleDeleteExternalSQLDirectory,
handleRemoveExternalSQLDirectory,
openExternalSQLFile,
openRenameExternalSQLFileModal,
handleDeleteExternalSQLFile,
extractObjectName,
});
const titleRender = useSidebarTitleRender({
connectionStates,
isV2Ui,
renderV2TreeTitle,
handleAddExternalSQLDirectory,
snapshotTreeSelectionBeforeDrag,
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
});
const handleDrop = (info: any) => {
setIsTreeDragging(false);
const dropPosition = normalizeSidebarTreeRelativeDropPosition(
Number(info.dropPosition || 0),
info?.node?.pos,
);
const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);
const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);
const insertBefore = resolveSidebarDropInsertBefore(dropPosition, dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null);
const dragNode = info.dragNode;
const dropNode = domDropNode && domDropNode.key === String(info?.node?.key || '')
? info.node
: (domDropNode
? findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key) || info.node
: info.node);
const getDropRootToken = (node: any): string => {
if (!node) return '';
if (node.type === 'tag') {
return buildSidebarRootTagToken(String(node?.dataRef?.id || ''));
}
if (node.type === 'connection') {
const groupedTagId = connectionTags.find((tag) =>
tag.connectionIds.includes(String(node.key)),
)?.id || '';
return groupedTagId
? buildSidebarRootTagToken(groupedTagId)
: buildSidebarRootConnectionToken(String(node.key));
}
return '';
};
// Root tag or ungrouped connection reordering
if (dragNode.type === 'tag') {
if (dropNode.type === 'tag' || dropNode.type === 'connection') {
const currentTagOrder = connectionTags.map(t => t.id);
const dragTagId = dragNode.dataRef.id;
const dropTagId = dropNode.type === 'tag'
? dropNode.dataRef.id
: (connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || '');
const dragRootToken = buildSidebarRootTagToken(String(dragTagId));
const dropRootToken = getDropRootToken(dropNode);
if (dropRootToken && dropRootToken !== dragRootToken) {
if (dropTagId) {
const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({
currentTagOrder,
dragTagId,
dropTagId,
relativeDropPosition: dropPosition,
fallbackInsertBefore: insertBefore,
metrics: dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null,
});
reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore);
} else {
reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore);
}
return;
}
const newOrder = currentTagOrder.filter(id => id !== dragTagId);
let insertIndex = newOrder.length;
if (dropTagId) {
const dropIndex = newOrder.indexOf(dropTagId);
const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({
currentTagOrder,
dragTagId,
dropTagId,
relativeDropPosition: dropPosition,
fallbackInsertBefore: insertBefore,
metrics: dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null,
});
if (resolvedInsertBefore) {
insertIndex = dropIndex;
} else {
insertIndex = dropIndex + 1;
}
} else {
// Dropped onto an ungrouped root connection, usually meaning moving to the end of tags
// Since tags are always displayed before ungrouped connections, just put it at the end
insertIndex = newOrder.length;
}
newOrder.splice(insertIndex, 0, dragTagId);
reorderTags(newOrder);
}
return;
}
if (dragNode.type === 'connection') {
const dragTagId = connectionTags.find((tag) =>
tag.connectionIds.includes(String(dragNode.key)),
)?.id || '';
const dragIsUngroupedRoot = !dragTagId;
const dropRootToken = getDropRootToken(dropNode);
if (dragIsUngroupedRoot && dropNode.type === 'connection' && dropRootToken) {
reorderSidebarRoot(
buildSidebarRootConnectionToken(String(dragNode.key)),
dropRootToken,
insertBefore,
);
return;
}
}
// Connection moving to tag (any drop position on a tag node counts as "into")
if (dragNode.type === 'connection' && dropNode.type === 'tag') {
moveConnectionToTag(dragNode.key, dropNode.dataRef.id);
return;
}
// Connection reordering against another connection
if (dragNode.type === 'connection' && dropNode.type === 'connection') {
const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key));
reorderConnections(
String(dragNode.key),
String(dropNode.key),
targetTag?.id || null,
insertBefore,
);
return;
}
};
const onRightClick = ({ event, node }: any) => {
if (isV2Ui && node?.type === 'v2-table-section') {
event.preventDefault();
event.stopPropagation();
return;
}
if (isV2Ui && node?.type === 'connection') {
openV2ConnectionContextMenu(event, node);
return;
}
if (isV2Ui && node?.type === 'database') {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-database',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
if (
isV2Ui
&& node?.type === 'object-group'
&& node?.dataRef?.groupKey === 'schema'
&& isPostgresSchemaDialect(getMetadataDialect(node.dataRef as SavedConnection))
&& String(node?.dataRef?.schemaName || '').trim()
) {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-schema',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
if (isV2Ui && node?.type === 'object-group' && node?.dataRef?.groupKey === 'tables') {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-table-group',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
if (isV2Ui && node?.type === 'table') {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-table',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
const items = getNodeMenuItems(node);
if (items && items.length > 0) {
setContextMenu({
x: event.clientX,
y: event.clientY,
items
});
}
};
const v2RailObjectActionsLabel = t('sidebar.rail.object_actions');
const v2RailSystemActionsLabel = t('sidebar.rail.system_actions');
const v2NewGroupLabel = t('sidebar.action.new_group');
const v2BatchTablesLabel = t('sidebar.action.batch_tables');
const v2BatchDatabasesLabel = t('sidebar.action.batch_databases');
const v2OpenExternalSqlFileLabel = t('sidebar.sql_file_exec.title');
const v2LocateCurrentTableLabel = t('sidebar.action.locate_current_table');
const v2LocateCurrentTableUnavailableLabel = t('sidebar.message.locate_current_table_unavailable');
const v2AiAssistantLabel = t('app.sidebar.ai_assistant');
const v2ToolsLabel = t('app.sidebar.tools');
const v2SettingsLabel = t('app.sidebar.settings');
const v2ActiveConnectionHeaderLabel = t('sidebar.active_connection.current_host_database');
const v2NoDatabaseSelectedLabel = t('sidebar.active_connection.no_database_selected');
const v2ConnectionActionsLabel = t('sidebar.active_connection.actions');
const v2CommandSearchLabel = t('sidebar.command_search.label');
const v2CommandSearchPlaceholder = t('sidebar.command_search.placeholder');
const v2CommandSearchPanelProps: SidebarSearchPanelProps<V2CommandSearchItem> = {
isOpen: isV2CommandSearchOpen,
searchValue: v2CommandSearchValue,
activeIndex: v2CommandActiveIndex,
label: v2CommandSearchLabel,
placeholder: v2CommandSearchPlaceholder,
persistedFilter: v2PersistedSidebarFilter,
persistentFilterEnabled: v2CommandSearchPersistentFilterEnabled,
aiMode: v2CommandSearchAiMode,
objectMode: v2CommandSearchObjectMode,
flatItems: commandSearchFlatItems,
sections: {
goTo: filteredCommandSearchTreeItems,
ai: commandSearchAiItem,
actions: filteredCommandSearchActionItems,
recent: filteredCommandSearchRecentItems,
},
inputRef: commandSearchInputRef,
handlers: {
onSearchValueChange: handleV2CommandSearchValueChange,
onKeyDown: handleV2CommandSearchKeyDown,
onClose: closeV2CommandSearch,
onItemSelect: (item: V2CommandSearchItem) => runCommandSearchItem(item),
onItemHover: (key: string) => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === key)),
onTogglePersistentFilter: toggleV2CommandSearchPersistentFilter,
onResetFilter: resetV2SidebarFilter,
},
};
// V2 Connection Rail 子组件 props从原 renderV2ConnectionRail 抽出,保留所有原行为)
const v2ConnectionRailProps = {
labels: {
railSystemActions: v2RailSystemActionsLabel,
railObjectActions: v2RailObjectActionsLabel,
newGroup: v2NewGroupLabel,
batchTables: v2BatchTablesLabel,
batchDatabases: v2BatchDatabasesLabel,
openExternalSqlFile: v2OpenExternalSqlFileLabel,
locateCurrentTable: v2LocateCurrentTableLabel,
locateCurrentTableUnavailable: v2LocateCurrentTableUnavailableLabel,
aiAssistant: v2AiAssistantLabel,
tools: v2ToolsLabel,
settings: v2SettingsLabel,
},
handlers: {
openCreateTagModal: () => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); },
openBatchTableExport: () => openBatchOperationModal(),
openBatchDatabaseExport: () => openBatchDatabaseModal(),
openExternalSqlFile: handleOpenSQLFileFromToolbar,
locateActiveTab: handleLocateActiveTabInSidebar,
toggleAI: onToggleAI ?? (() => {}),
openTools: onOpenTools ?? (() => {}),
openSettings: onOpenSettings ?? (() => {}),
},
canLocateActiveTab,
};
return (
<div className={isV2Ui ? 'gn-v2-sidebar-redesign' : undefined} style={{ display: 'flex', height: '100%', minHeight: 0 }}>
{exportProgressModal}
{isV2Ui && <SidebarConnectionRail {...v2ConnectionRailProps} />}
<div className={isV2Ui ? 'gn-v2-object-explorer' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0, flex: 1 }}>
{isV2Ui && (
<div className="gn-v2-active-connection-header" data-object-count={activeConnectionObjectCount}>
<div className="gn-v2-active-connection-trigger" aria-label={v2ActiveConnectionHeaderLabel}>
<span className={`gn-v2-live-dot is-${activeConnection ? buildRailConnectionStatus(activeConnection.id) : 'idle'}`} />
<div className="gn-v2-active-connection-copy">
<strong>{activeConnectionDisplayName}</strong>
<span>{activeDatabaseDisplayName || v2NoDatabaseSelectedLabel}</span>
</div>
</div>
<div className="gn-v2-active-connection-actions">
<Tooltip title={t('sidebar.menu.new_query')}>
<Button
size="small"
type="text"
className="gn-v2-active-connection-query-action"
icon={<FileTextOutlined />}
aria-label={t('sidebar.menu.new_query')}
data-gonavi-new-query-action="true"
disabled={!activeConnection}
onClick={() => {
if (activeConnection) {
handleV2ConnectionContextMenuAction(getConnectionNodeForAction(activeConnection), 'new-query');
}
}}
>
{t('sidebar.menu.new_query')}
</Button>
</Tooltip>
{onCreateConnection && (
<Tooltip title={t('connection.new')}>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
aria-label={t('connection.new')}
data-gonavi-create-connection-action="true"
onClick={onCreateConnection}
/>
</Tooltip>
)}
<Tooltip title={v2ConnectionActionsLabel}>
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={v2ConnectionActionsLabel}
disabled={!activeConnection}
onClick={(event) => {
if (activeConnection) {
openV2ConnectionContextMenu(event, activeConnection);
}
}}
/>
</Tooltip>
</div>
</div>
)}
<div className={isV2Ui ? 'gn-v2-explorer-search' : undefined} style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
{isV2Ui && !v2UseLegacySidebarFilter ? (
<div className="gn-v2-explorer-command-row" data-v2-sidebar-search-mode="command">
<button
type="button"
className="gn-v2-explorer-command-trigger"
onClick={() => {
openV2CommandSearch();
onFocusCommandSearch?.();
}}
aria-label={v2CommandSearchLabel}
>
<SearchOutlined />
<span>{v2PersistedSidebarFilter || v2CommandSearchPlaceholder}</span>
{focusSidebarSearchShortcutTokens.length > 0 ? (
<span className="gn-v2-search-shortcut" aria-hidden="true">
{focusSidebarSearchShortcutTokens.map((token, index) => (
<kbd key={`${token}-${index}`}>{token}</kbd>
))}
</span>
) : null}
</button>
<Tooltip title={v2PersistedSidebarFilter ? t('sidebar.command_search.reset_filter') : t('sidebar.command_search.no_synced_filter')}>
<button
type="button"
className="gn-v2-explorer-filter-action"
aria-label={t('sidebar.command_search.reset_filter')}
disabled={!v2PersistedSidebarFilter}
onClick={resetV2SidebarFilter}
>
<ReloadOutlined />
</button>
</Tooltip>
</div>
) : isV2Ui ? (
<div className="gn-v2-explorer-legacy-filter-row" data-v2-sidebar-search-mode="filter">
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
value={searchValue}
placeholder={t('sidebar.search.placeholder')}
onChange={onSearch}
size="small"
prefix={<SearchOutlined />}
/>
<Tooltip title={searchValue ? t('sidebar.command_search.reset_filter') : t('sidebar.command_search.no_filter_content')}>
<button
type="button"
className="gn-v2-explorer-filter-action"
aria-label={t('sidebar.command_search.reset_filter')}
disabled={!searchValue}
onClick={resetV2SidebarFilter}
>
<ReloadOutlined />
</button>
</Tooltip>
</div>
) : (
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
placeholder={t('sidebar.search.placeholder')}
onChange={onSearch}
size="small"
prefix={<SearchOutlined style={{ color: darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)' }} />}
style={{
borderRadius: 6,
border: 'none',
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)',
boxShadow: 'none',
padding: '4px 8px',
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)',
}}
suffix={(
<Popover
content={searchScopePopoverContent}
trigger="click"
placement="bottomRight"
open={isSearchScopePopoverOpen}
onOpenChange={setIsSearchScopePopoverOpen}
styles={{ body: { padding: 0, borderRadius: 16, overflow: 'hidden' } }}
>
<Tooltip title={t('sidebar.command_search.scope.tooltip', { scope: searchScopeSummary })}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
background: isSearchScopePopoverOpen
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)')
: 'transparent',
transition: 'background 0.2s',
color: searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'),
}}
onMouseEnter={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)';
}
}}
onMouseLeave={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)');
}
}}
>
<FilterOutlined style={{ fontSize: 13 }} />
<span style={{ fontSize: 12, fontWeight: 500 }}>
{searchScopes.includes('smart') ? t('sidebar.command_search.scope.compact_smart') : searchScopes.length}
</span>
</div>
</Tooltip>
</Popover>
)}
/>
)}
</div>
{isV2Ui && (
<div className="gn-v2-explorer-filter-tabs" aria-label={t('sidebar.command_search.object_kind.filter_aria')}>
{V2_EXPLORER_FILTER_OPTIONS.map((item) => (
<button
key={item.key}
type="button"
className={v2ExplorerFilter === item.key ? 'is-active' : undefined}
aria-pressed={v2ExplorerFilter === item.key}
onClick={() => setV2ExplorerFilter(item.key)}
>
{t(item.labelKey)}
</button>
))}
</div>
)}
{/* Toolbar */}
{!isV2Ui && (
<div data-sidebar-legacy-toolbar="true" style={legacyToolbarStyle}>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={t('sidebar.action.new_group')}>
<Button
size="small"
type="text"
icon={<FolderOpenOutlined />}
aria-label={t('sidebar.action.new_group')}
data-sidebar-create-group-action="true"
onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={t('sidebar.action.batch_tables')}>
<Button
size="small"
type="text"
icon={<TableOutlined />}
aria-label={t('sidebar.action.batch_tables')}
data-sidebar-batch-table-action="true"
onClick={() => openBatchOperationModal()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={t('sidebar.action.batch_databases')}>
<Button
size="small"
type="text"
icon={<DatabaseOutlined />}
aria-label={t('sidebar.action.batch_databases')}
data-sidebar-batch-database-action="true"
onClick={() => openBatchDatabaseModal()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={v2OpenExternalSqlFileLabel}>
<Button
size="small"
type="text"
icon={<FileAddOutlined />}
aria-label={v2OpenExternalSqlFileLabel}
data-sidebar-open-external-sql-file-action="true"
onClick={handleOpenSQLFileFromToolbar}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={canLocateActiveTab ? t('sidebar.action.locate_current_tab') : t('sidebar.message.locate_current_tab_unavailable')}>
<span style={legacyToolbarDisabledWrapStyle}>
<Button
size="small"
type="text"
icon={<AimOutlined />}
aria-label={t('sidebar.action.locate_current_tab')}
data-sidebar-locate-current-tab-action="true"
disabled={!canLocateActiveTab}
onClick={handleLocateActiveTabInSidebar}
style={{ color: legacyToolbarButtonColor }}
/>
</span>
</Tooltip>
</div>
</div>
)}
<div
ref={treeContainerRef}
className={`sidebar-tree-scroll-shell${isV2Ui ? ' gn-v2-explorer-tree-shell' : ''}`}
style={{
flex: 1,
overflow: 'hidden',
minHeight: 0,
}}
>
<div className="sidebar-tree-scroll-content">
<Tree
key={isV2Ui ? `v2-tree-${v2ExplorerFilter}` : 'legacy-tree'}
ref={treeRef}
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
onDragStart={() => {
snapshotTreeSelectionBeforeDrag();
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
setIsTreeDragging(true);
}}
onDragEnter={() => {
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
setIsTreeDragging(true);
}}
onDragEnd={() => {
restoreTreeSelectionAfterDrag();
setIsTreeDragging(false);
}}
onDrop={handleDrop}
loadData={onLoadData}
treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}
onDoubleClick={onDoubleClick}
onSelect={onSelect}
titleRender={titleRender}
switcherIcon={renderSidebarSwitcherIcon}
expandedKeys={expandedKeys}
onExpand={onExpand}
loadedKeys={loadedKeys}
onLoad={setLoadedKeys}
autoExpandParent={autoExpandParent}
selectedKeys={selectedKeys}
blockNode
height={effectiveTreeHeight}
scrollWidth={isV2Ui ? v2TreeHorizontalScrollWidth : undefined}
onRightClick={onRightClick}
/>
</div>
</div>
{isV2Ui && (
<div className="gn-v2-sidebar-log-footer">
<SlowQueryRailButton
className="gn-v2-sidebar-slow-query-button"
tooltipPlacement="top"
/>
</div>
)}
</div>
<SidebarSearchPanel {...v2CommandSearchPanelProps} />
{contextMenu?.kind && typeof document !== 'undefined' && createPortal(
<div
ref={contextMenuPortalRef}
className={`gn-v2-sidebar-context-menu-portal ${contextMenu.rootClassName || ''}`}
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 10000,
width: contextMenu.overlayStyle?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH,
maxWidth: contextMenu.overlayStyle?.maxWidth ?? 'calc(100vw - 24px)',
['--gn-v2-context-menu-max-height' as any]: `${contextMenu.maxHeight ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT}px`,
}}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
{renderV2SidebarContextMenuContent(contextMenu)}
</div>,
document.body,
)}
{contextMenu && !contextMenu.kind && (
<Dropdown
menu={{ items: contextMenu.items }}
open={true}
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
trigger={['contextMenu']}
rootClassName={contextMenu.rootClassName}
overlayStyle={contextMenu.overlayStyle}
>
<div style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, width: 1, height: 1 }} />
</Dropdown>
)}
<SidebarEntityModals
connections={connections}
connectionTags={connectionTags}
modalPanelStyle={modalPanelStyle}
modalSectionStyle={modalSectionStyle}
modalScrollSectionStyle={modalScrollSectionStyle}
renderSidebarModalTitle={renderSidebarModalTitle}
isCreateTagModalOpen={isCreateTagModalOpen}
setIsCreateTagModalOpen={setIsCreateTagModalOpen}
createTagForm={createTagForm}
renameViewTarget={renameViewTarget}
updateConnectionTag={updateConnectionTag}
addConnectionTag={addConnectionTag}
moveConnectionToTag={moveConnectionToTag}
isCreateDbModalOpen={isCreateDbModalOpen}
setIsCreateDbModalOpen={setIsCreateDbModalOpen}
createDbForm={createDbForm}
handleCreateDatabase={handleCreateDatabase}
isCreateSchemaModalOpen={isCreateSchemaModalOpen}
setIsCreateSchemaModalOpen={setIsCreateSchemaModalOpen}
createSchemaForm={createSchemaForm}
createSchemaTarget={createSchemaTarget}
setCreateSchemaTarget={setCreateSchemaTarget}
handleCreateSchema={handleCreateSchema}
isRenameSchemaModalOpen={isRenameSchemaModalOpen}
setIsRenameSchemaModalOpen={setIsRenameSchemaModalOpen}
renameSchemaForm={renameSchemaForm}
renameSchemaTarget={renameSchemaTarget}
setRenameSchemaTarget={setRenameSchemaTarget}
handleRenameSchema={handleRenameSchema}
isRenameDbModalOpen={isRenameDbModalOpen}
setIsRenameDbModalOpen={setIsRenameDbModalOpen}
renameDbForm={renameDbForm}
renameDbTarget={renameDbTarget}
setRenameDbTarget={setRenameDbTarget}
handleRenameDatabase={handleRenameDatabase}
isRenameTableModalOpen={isRenameTableModalOpen}
setIsRenameTableModalOpen={setIsRenameTableModalOpen}
renameTableForm={renameTableForm}
renameTableTarget={renameTableTarget}
setRenameTableTarget={setRenameTableTarget}
handleRenameTable={handleRenameTable}
isRenameViewModalOpen={isRenameViewModalOpen}
setIsRenameViewModalOpen={setIsRenameViewModalOpen}
renameViewForm={renameViewForm}
setRenameViewTarget={setRenameViewTarget}
handleRenameView={handleRenameView}
isRenameSavedQueryModalOpen={isRenameSavedQueryModalOpen}
setIsRenameSavedQueryModalOpen={setIsRenameSavedQueryModalOpen}
renameSavedQueryForm={renameSavedQueryForm}
renameSavedQueryTarget={renameSavedQueryTarget}
setRenameSavedQueryTarget={setRenameSavedQueryTarget}
handleRenameSavedQuery={handleRenameSavedQuery}
/>
<ExternalSQLFileModal {...externalSQLFileModalProps} />
<SidebarBatchExportModals
connections={connections}
modalPanelStyle={modalPanelStyle}
modalSectionStyle={modalSectionStyle}
modalScrollSectionStyle={modalScrollSectionStyle}
modalHintTextStyle={modalHintTextStyle}
darkMode={darkMode}
tableModalTitle={renderSidebarModalTitle(
<TableOutlined />,
t('sidebar.modal.batch_tables.title'),
t('sidebar.modal.batch_tables.description'),
)}
databaseModalTitle={renderSidebarModalTitle(
<DatabaseOutlined />,
t('sidebar.modal.batch_databases.title'),
t('sidebar.modal.batch_databases.description'),
)}
isBatchModalOpen={isBatchModalOpen}
setIsBatchModalOpen={setIsBatchModalOpen}
selectedConnection={selectedConnection}
selectedDatabase={selectedDatabase}
availableDatabases={availableDatabases}
batchTables={batchTables}
checkedTableKeys={checkedTableKeys}
setCheckedTableKeys={setCheckedTableKeys}
batchFilterKeyword={batchFilterKeyword}
setBatchFilterKeyword={setBatchFilterKeyword}
batchFilterType={batchFilterType}
setBatchFilterType={setBatchFilterType}
batchSelectionScope={batchSelectionScope}
setBatchSelectionScope={setBatchSelectionScope}
filteredBatchObjects={filteredBatchObjects}
groupedBatchObjects={groupedBatchObjects}
selectionScopeTargetKeys={selectionScopeTargetKeys}
handleConnectionChange={handleConnectionChange}
handleDatabaseChange={handleDatabaseChange}
handleBatchClear={handleBatchClear}
handleBatchDeleteTables={handleBatchDeleteTables}
handleBatchExport={handleBatchExport}
handleCheckAll={handleCheckAll}
handleInvertSelection={handleInvertSelection}
isBatchDbModalOpen={isBatchDbModalOpen}
setIsBatchDbModalOpen={setIsBatchDbModalOpen}
selectedDbConnection={selectedDbConnection}
batchDatabases={batchDatabases}
checkedDbKeys={checkedDbKeys}
setCheckedDbKeys={setCheckedDbKeys}
handleDbConnectionChange={handleDbConnectionChange}
handleBatchDbExport={handleBatchDbExport}
handleBatchDbDelete={handleBatchDbDelete}
handleCheckAllDb={handleCheckAllDb}
handleInvertSelectionDb={handleInvertSelectionDb}
/>
<FindInDatabaseModal
open={findInDbContext.open}
onClose={() => setFindInDbContext({ open: false, connectionId: '', dbName: '' })}
connectionId={findInDbContext.connectionId}
dbName={findInDbContext.dbName}
/>
<MessagePublishModal
open={Boolean(messagePublishTarget)}
connection={messagePublishTarget?.connection || null}
executionDbName={messagePublishTarget?.executionDbName || ''}
defaultDestination={messagePublishTarget?.destination || ''}
onCancel={() => setMessagePublishTarget(null)}
onSuccess={handleMessagePublishSuccess}
/>
</div>
);
});
export default Sidebar;