Files
MyGoNavi/frontend/src/components/Sidebar.tsx
Syngnat aee4da47db 🐛 fix(sidebar): 调整折叠状态下的展开按钮位置
- 将 V2 侧栏展开按钮从标题栏移至固定工具栏顶部
- 保持展开与折叠后的焦点回迁和无障碍属性
- 补充固定栏按钮顺序、交互和样式回归测试
2026-07-24 13:59:43 +08:00

3613 lines
145 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 Modal from './common/ResizableDraggableModal';
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
import SlowQueryRailButton from './sidebar/SlowQueryRailButton';
import SqlAuditRailButton from './sidebar/SqlAuditRailButton';
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
import {
getMetadataDialect,
loadSchemas,
shouldHideSchemaPrefix,
splitQualifiedName,
} from './sidebar/sidebarMetadataLoaders';
import {
useSidebarBatchExport,
} from './sidebar/useSidebarBatchExport';
import { SidebarEntityModals } from './sidebar/SidebarEntityModals';
import { SavedQueryGroupModal } from './sidebar/SavedQueryGroupModal';
import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle';
import {
useSidebarV2ContextMenu,
} from './sidebar/useSidebarV2ContextMenu';
import {
useSidebarObjectActions,
type SidebarMessagePublishTarget,
} from './sidebar/useSidebarObjectActions';
import { useSidebarSearchModel } from './sidebar/useSidebarSearchModel';
import { useSidebarFilterPersistence } from './sidebar/useSidebarFilterPersistence';
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,
resolveSidebarDatabaseNameForCopy,
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, Radio, Select, Tooltip } from 'antd';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import {
CaretDownFilled,
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
ExportOutlined,
FolderAddOutlined,
SaveOutlined,
EditOutlined,
SearchOutlined,
KeyOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
FunctionOutlined,
LinkOutlined,
FileAddOutlined,
ImportOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
DeleteOutlined,
DisconnectOutlined,
CheckSquareOutlined,
FilterOutlined,
DashboardOutlined,
WarningOutlined,
AimOutlined,
MoreOutlined,
MenuFoldOutlined,
SettingOutlined
} from '@ant-design/icons';
import {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
useStore,
} from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import {
selectRecentSidebarSqlLogs,
selectSidebarCommandSearchSqlLogs,
} from './sidebar/sidebarSqlLogSelector';
import { SavedConnection, SavedQuery, SavedQueryGroup, ExternalSQLDirectory, ExternalSQLTreeEntry, SchemaVisibilityRule } 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 { useWorkbenchTabs } from '../hooks/useWorkbenchTabs';
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 {
getSavedQueryGroupIdFromToken,
getSavedQueryGroupOwnerIds,
getSavedQueryIdFromGroupToken,
isSavedQueryGroupQueryToken,
isSavedQueryGroupToken,
normalizeSavedQueryGroups,
resolveSavedQueryGroupChildOrder,
} from '../utils/savedQueryGroups';
import {
getSchemaVisibilityRule,
moveSchemaVisibilityRule,
updateSchemaVisibilityRule,
} from '../utils/schemaVisibility';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import {
DATA_IMPORT_WORKBENCH_TAB_ID,
resolveDataImportWorkbenchLaunchTab,
} from '../utils/dataImportTab';
import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import { resolveSidebarTableMetadataFields } from '../utils/sidebarTableMetadata';
import { filterSidebarTreeByHiddenObjectGroups } from '../utils/sidebarObjectVisibility';
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,
buildSidebarConnectionTagTree,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
collectSidebarSubtreeKeys,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
isSidebarTablePinned,
isConnectionTagDescendant,
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarDatabaseTreePruneKeys,
resolveSidebarNodeConnectionId,
resolveV2ActiveConnectionId,
resolveV2SelectedDatabaseName,
resolveV2CommandSearchPersistentFilter,
shouldClearSidebarNodeChildrenOnCollapse,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
type SidebarConnectionState,
type SidebarTreeNode as TreeNode,
type V2CommandSearchItem,
} from './sidebarV2Utils';
export {
buildSidebarTableChildrenForUi,
buildSidebarConnectionTagTree,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
collectSidebarSubtreeKeys,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
isSidebarTablePinned,
isConnectionTagDescendant,
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarDatabaseTreePruneKeys,
resolveSidebarNodeConnectionId,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldClearSidebarNodeChildrenOnCollapse,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
};
export { resolveSidebarTagDropInsertBefore } from './sidebarV2Utils';
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 || [],
schemaVisibilityByDatabase: conn.schemaVisibilityByDatabase || {},
});
};
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[],
savedQueryGroups: SavedQueryGroup[] = [],
): TreeNode | null => {
const normalizedGroups = normalizeSavedQueryGroups(
savedQueryGroups,
savedQueries.map((query) => query.id),
);
if (savedQueries.length === 0 && normalizedGroups.length === 0) {
return null;
}
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 buildAutomaticChildren = (queries: SavedQuery[]): TreeNode[] => {
const connectionIds = new Set(connections.map((conn) => conn.id));
const unmatchedSavedQueries = queries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds));
const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id));
const groupedByConnection = new Map<string, SavedQuery[]>();
queries.forEach((query) => {
if (unmatchedIds.has(query.id)) return;
groupedByConnection.set(query.connectionId, [
...(groupedByConnection.get(query.connectionId) || []),
query,
]);
});
const automaticChildren: TreeNode[] = [];
connections.forEach((conn) => {
const connectionQueries = groupedByConnection.get(conn.id);
if (!connectionQueries || connectionQueries.length === 0) return;
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
automaticChildren.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,
]);
});
automaticChildren.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 automaticChildren;
};
const queryById = new Map(savedQueries.map((query) => [query.id, query]));
const groupById = new Map(normalizedGroups.map((group) => [group.id, group]));
const groupOwners = getSavedQueryGroupOwnerIds(normalizedGroups);
const buildManualGroupNode = (group: SavedQueryGroup, ancestors = new Set<string>()): TreeNode => {
const nextAncestors = new Set(ancestors);
nextAncestors.add(group.id);
const children = resolveSavedQueryGroupChildOrder(group.id, normalizedGroups).flatMap((token): TreeNode[] => {
if (isSavedQueryGroupQueryToken(token)) {
const query = queryById.get(getSavedQueryIdFromGroupToken(token));
return query ? [createQueryNode(query)] : [];
}
if (isSavedQueryGroupToken(token)) {
const childGroupId = getSavedQueryGroupIdFromToken(token);
const childGroup = groupById.get(childGroupId);
if (!childGroup || childGroup.parentGroupId !== group.id || nextAncestors.has(childGroup.id)) return [];
return [buildManualGroupNode(childGroup, nextAncestors)];
}
return [];
});
return {
title: group.name || t('sidebar.saved_query_group.untitled'),
key: `saved-query-manual-group-${group.id}`,
icon: <FolderOutlined />,
type: 'saved-query-manual-group',
dataRef: group,
selectable: false,
isLeaf: false,
children,
};
};
const automaticChildren = buildAutomaticChildren(
savedQueries.filter((query) => !groupOwners.has(query.id)),
);
const children: TreeNode[] = normalizedGroups
.filter((group) => !group.parentGroupId)
.map((group) => buildManualGroupNode(group));
if (normalizedGroups.length === 0) {
children.push(...automaticChildren);
} else if (automaticChildren.length > 0) {
children.push({
title: t('sidebar.tree.ungrouped_saved_queries'),
key: 'all-saved-queries-ungrouped',
icon: <FolderOpenOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: automaticChildren,
});
}
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;
onOpenSettings?: () => void;
onToggleAI?: () => void;
onToggleLogPanel?: () => void;
uiVersion?: 'legacy' | 'v2';
onFocusCommandSearch?: () => void;
onCollapseSidebar?: () => void;
onExpandSidebar?: () => void;
collapseSidebarLabel?: string;
collapseSidebarButtonRef?: React.Ref<HTMLButtonElement>;
expandSidebarLabel?: string;
expandSidebarButtonRef?: React.Ref<HTMLButtonElement>;
isTreePanelCollapsed?: boolean;
}> = React.memo(({
onCreateConnection,
onEditConnection,
onOpenSettings,
onToggleAI,
onToggleLogPanel,
uiVersion,
onFocusCommandSearch,
onCollapseSidebar,
onExpandSidebar,
collapseSidebarLabel,
collapseSidebarButtonRef,
expandSidebarLabel,
expandSidebarButtonRef,
isTreePanelCollapsed = false,
}) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const savedQueryGroups = useStore(state => state.savedQueryGroups);
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
const saveQuery = useStore(state => state.saveQuery);
const deleteQuery = useStore(state => state.deleteQuery);
const saveSavedQueryGroup = useStore(state => state.saveSavedQueryGroup);
const deleteSavedQueryGroup = useStore(state => state.deleteSavedQueryGroup);
const moveSavedQueryToGroup = useStore(state => state.moveSavedQueryToGroup);
const reloadSavedQueryGroups = useStore(state => state.reloadSavedQueryGroups);
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
const updateRecentSQLFilePath = useStore(state => state.updateRecentSQLFilePath);
const removeRecentSQLFilesByPath = useStore(state => state.removeRecentSQLFilesByPath);
const moveRecentSQLFilesByDirectory = useStore(state => state.moveRecentSQLFilesByDirectory);
const removeRecentSQLFilesByDirectory = useStore(state => state.removeRecentSQLFilesByDirectory);
const addConnection = useStore(state => state.addConnection);
const updateConnection = useStore(state => state.updateConnection);
const addTab = useStore(state => state.addTab);
const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft);
const tabs = useWorkbenchTabs();
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 moveConnectionTag = useStore(state => state.moveConnectionTag);
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 hideSqlLogFromRecent = useStore(state => state.hideSqlLogFromRecent);
const clearRecentSqlLogs = useStore(state => state.clearRecentSqlLogs);
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 commandSearchSqlLogs = useStore(
state => selectSidebarCommandSearchSqlLogs(state, isV2Ui && isV2CommandSearchOpen),
);
const recentSqlLogs = useMemo(
() => selectRecentSidebarSqlLogs(commandSearchSqlLogs),
[commandSearchSqlLogs],
);
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, savedQueryGroups);
}, [connections, savedQueries, savedQueryGroups]);
const sidebarHiddenObjectGroups = appearance.sidebarHiddenObjectGroups;
const visibleSidebarTreeData = useMemo(
() => filterSidebarTreeByHiddenObjectGroups(treeData, sidebarHiddenObjectGroups),
[sidebarHiddenObjectGroups, treeData],
);
const sidebarObjectVisibilitySignature = sidebarHiddenObjectGroups.join('|') || 'all';
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]);
const persistV2SidebarFilter = useCallback((nextFilter: string) => {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}, [setAppearance]);
useSidebarFilterPersistence({
enabled: v2UseLegacySidebarFilter,
searchValue,
persistedFilter: v2PersistedSidebarFilter,
onPersist: persistV2SidebarFilter,
});
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 [schemaVisibilityForm] = Form.useForm<{
mode: SchemaVisibilityRule['mode'];
schemas: string[];
}>();
const [schemaVisibilityTarget, setSchemaVisibilityTarget] = useState<{
connection: SavedConnection;
dbName: string;
databaseNodeKey: React.Key;
availableSchemas: string[];
} | null>(null);
const [isSavingSchemaVisibility, setIsSavingSchemaVisibility] = useState(false);
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);
const [isSavedQueryGroupModalOpen, setIsSavedQueryGroupModalOpen] = useState(false);
const [savedQueryGroupTargetId, setSavedQueryGroupTargetId] = useState<string | null>(null);
const [savedQueryGroupInitialParentId, setSavedQueryGroupInitialParentId] = useState<string | null>(null);
// Connection Tag Modals
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
const [createTagForm] = Form.useForm();
const {
handleExportDatabaseSQL,
handleExportSchemaSQL,
openBatchTableWorkbench,
openBatchDatabaseWorkbench,
} = useSidebarBatchExport({
connections,
selectedNodesRef,
addTab,
});
// 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 buildTreeNode = (item: ReturnType<typeof buildSidebarConnectionTagTree>[number]): TreeNode => {
if (item.kind === 'connection') {
return buildConnectionNode(item.connection);
}
return {
title: item.tag.name,
key: `tag-${item.tag.id}`,
icon: (
<span
className="gn-v2-tree-folder-icon"
data-sidebar-tree-folder-icon="true"
>
<FolderOutlined />
</span>
),
type: 'tag',
dataRef: item.tag,
isLeaf: false,
children: item.children.map(buildTreeNode),
} as TreeNode;
};
const orderedNodes = buildSidebarConnectionTagTree(
connections,
connectionTags,
sidebarRootOrder,
).map(buildTreeNode);
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,
dataRef?: unknown,
): TreeNode[] => {
return list.map(node => {
if (node.key === key) {
return {
...node,
children,
...(dataRef === undefined ? {} : { dataRef }),
};
}
if (node.children) {
return {
...node,
children: updateTreeData(node.children, key, children, dataRef),
};
}
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,
dataRef?: unknown,
): TreeNode[] => {
const nextTreeData = updateTreeData(treeDataRef.current, key, children, dataRef);
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,
updateRecentSQLFilePath,
removeRecentSQLFilesByPath,
moveRecentSQLFilesByDirectory,
removeRecentSQLFilesByDirectory,
refreshGlobalExternalSQLRootNode,
setExpandedKeys,
setAutoExpandParent,
getActiveContext: () => useStore.getState().activeContext,
});
useEffect(() => {
const handleWorkbenchAddExternalSQLDirectory = () => {
void handleAddExternalSQLDirectory({ type: 'external-sql-root' });
};
window.addEventListener('gonavi:add-external-sql-directory', handleWorkbenchAddExternalSQLDirectory);
return () => {
window.removeEventListener('gonavi:add-external-sql-directory', handleWorkbenchAddExternalSQLDirectory);
};
}, [handleAddExternalSQLDirectory]);
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 === 'saved-query-manual-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 {
title: dbName,
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 openSavedQueryGroupModal = useCallback(async (
target?: SavedQueryGroup | null,
initialParentGroupId?: string | null,
) => {
try {
const groups = await reloadSavedQueryGroups();
const targetId = String(target?.id || '').trim();
if (targetId && !groups.some((group) => group.id === targetId)) {
message.warning(t('sidebar.message.saved_query_group_not_found'));
return;
}
const parentId = String(initialParentGroupId || '').trim();
setSavedQueryGroupTargetId(targetId || null);
setSavedQueryGroupInitialParentId(
parentId && groups.some((group) => group.id === parentId) ? parentId : null,
);
setIsSavedQueryGroupModalOpen(true);
} catch (error) {
message.error(t('sidebar.message.saved_query_group_load_failed', {
error: error instanceof Error ? error.message : String(error),
}));
}
}, [reloadSavedQueryGroups]);
const closeSavedQueryGroupModal = useCallback(() => {
setIsSavedQueryGroupModalOpen(false);
setSavedQueryGroupTargetId(null);
setSavedQueryGroupInitialParentId(null);
}, []);
const handleSaveSavedQueryGroup = useCallback(async (group: SavedQueryGroup) => {
const isEditing = Boolean(group.id);
await saveSavedQueryGroup(group);
message.success(t(
isEditing
? 'sidebar.message.saved_query_group_updated'
: 'sidebar.message.saved_query_group_created',
));
}, [saveSavedQueryGroup]);
const savedQueryGroupTarget = useMemo(
() => savedQueryGroups.find((group) => group.id === savedQueryGroupTargetId) || null,
[savedQueryGroupTargetId, savedQueryGroups],
);
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 openSchemaVisibilitySettings = useCallback((node: any) => {
const dbName = String(node?.dataRef?.dbName || node?.title || '').trim();
const connectionId = String(node?.dataRef?.id || '').trim();
const connection = connections.find((item) => item.id === connectionId) || node?.dataRef;
if (!connection || !dbName || !shouldHideSchemaPrefix(connection as SavedConnection)) {
return;
}
const databaseNode = node?.type === 'database'
? node
: getDatabaseNodeRef(connection, dbName);
const currentRule = getSchemaVisibilityRule(connection as SavedConnection, dbName);
const availableSchemas = Array.from(new Set([
...(Array.isArray(databaseNode?.children)
? databaseNode.children
.filter((item: any) => item?.dataRef?.groupKey === 'schema')
.map((item: any) => String(item?.dataRef?.schemaName || item?.title || '').trim())
: []),
...(currentRule?.schemas || []),
].filter(Boolean))).sort((a, b) => a.localeCompare(b));
schemaVisibilityForm.setFieldsValue({
mode: currentRule?.mode || 'include',
schemas: currentRule?.schemas || [],
});
setSchemaVisibilityTarget({
connection: connection as SavedConnection,
dbName,
databaseNodeKey: databaseNode?.key || `${connectionId}-${dbName}`,
availableSchemas,
});
void loadSchemas(connection as SavedConnection, dbName).then((result) => {
const loadedSchemas = Array.isArray(result?.schemas)
? result.schemas.map((schema) => String(schema || '').trim()).filter(Boolean)
: [];
if (loadedSchemas.length === 0) return;
setSchemaVisibilityTarget((current) => {
if (!current || current.connection.id !== connectionId || current.dbName !== dbName) {
return current;
}
return {
...current,
availableSchemas: Array.from(new Set([
...current.availableSchemas,
...loadedSchemas,
])).sort((left, right) => left.localeCompare(right)),
};
});
}).catch(() => undefined);
}, [connections, getDatabaseNodeRef, schemaVisibilityForm]);
const handleSaveSchemaVisibility = useCallback(async () => {
if (!schemaVisibilityTarget) return;
setIsSavingSchemaVisibility(true);
try {
const values = await schemaVisibilityForm.validateFields();
const mode = values.mode === 'exclude' ? 'exclude' : 'include';
const seenSchemas = new Set<string>();
const schemas = (Array.isArray(values.schemas) ? values.schemas : [])
.map((schema) => String(schema || '').trim())
.filter((schema) => {
const normalized = schema.toLocaleLowerCase();
if (!normalized || seenSchemas.has(normalized)) return false;
seenSchemas.add(normalized);
return true;
});
const nextRule: SchemaVisibilityRule | undefined = schemas.length > 0
? { mode, schemas }
: undefined;
const nextConnection = updateSchemaVisibilityRule(
schemaVisibilityTarget.connection,
schemaVisibilityTarget.dbName,
nextRule,
);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.SaveConnection !== 'function') {
throw new Error(t('connection_modal.message.save_failed'));
}
const saved = await backendApp.SaveConnection({
id: nextConnection.id,
name: nextConnection.name,
config: nextConnection.config,
includeDatabases: nextConnection.includeDatabases,
includeRedisDatabases: nextConnection.includeRedisDatabases,
schemaVisibilityByDatabase: nextConnection.schemaVisibilityByDatabase,
iconType: nextConnection.iconType,
iconColor: nextConnection.iconColor,
});
const persistedConnection: SavedConnection = {
...nextConnection,
...(saved || {}),
schemaVisibilityByDatabase: nextConnection.schemaVisibilityByDatabase,
};
connectionReloadSignaturesRef.current[persistedConnection.id] =
buildConnectionReloadSignature(persistedConnection);
updateConnection(persistedConnection);
await loadTables({
key: schemaVisibilityTarget.databaseNodeKey,
type: 'database',
dataRef: {
...persistedConnection,
dbName: schemaVisibilityTarget.dbName,
},
});
setExpandedKeys((previous) => previous.includes(schemaVisibilityTarget.databaseNodeKey)
? previous
: [...previous, schemaVisibilityTarget.databaseNodeKey]);
setSchemaVisibilityTarget(null);
message.success(t('sidebar.schema_visibility.message.saved'));
} catch (error: any) {
message.error(t('sidebar.schema_visibility.message.save_failed', {
error: error?.message || String(error),
}));
} finally {
setIsSavingSchemaVisibility(false);
}
}, [loadTables, schemaVisibilityForm, schemaVisibilityTarget, updateConnection]);
const migrateSchemaVisibilityForRenamedDatabase = useCallback(async (
connection: SavedConnection,
oldDbName: string,
newDbName: string,
): Promise<SavedConnection> => {
const currentConnection = connections.find((item) => item.id === connection.id) || connection;
const nextConnection = moveSchemaVisibilityRule(currentConnection, oldDbName, newDbName);
if (nextConnection === currentConnection) {
return currentConnection;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.SaveConnection !== 'function') {
message.warning(t('sidebar.schema_visibility.message.save_failed', {
error: t('connection_modal.message.save_failed'),
}));
return currentConnection;
}
try {
const saved = await backendApp.SaveConnection({
id: nextConnection.id,
name: nextConnection.name,
config: nextConnection.config,
includeDatabases: nextConnection.includeDatabases,
includeRedisDatabases: nextConnection.includeRedisDatabases,
schemaVisibilityByDatabase: nextConnection.schemaVisibilityByDatabase,
iconType: nextConnection.iconType,
iconColor: nextConnection.iconColor,
});
const persistedConnection: SavedConnection = {
...nextConnection,
...(saved || {}),
schemaVisibilityByDatabase: nextConnection.schemaVisibilityByDatabase,
};
connectionReloadSignaturesRef.current[persistedConnection.id] =
buildConnectionReloadSignature(persistedConnection);
updateConnection(persistedConnection);
return persistedConnection;
} catch (error: any) {
message.warning(t('sidebar.schema_visibility.message.save_failed', {
error: error?.message || String(error),
}));
return currentConnection;
}
}, [connections, updateConnection]);
const {
handleCopyStructure,
handleCopyTable,
handleCopyTableName,
handleCopyDatabaseName,
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,
handleRevealSavedQueryInFolder,
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,
migrateSchemaVisibilityForRenamedDatabase,
});
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,
handleCopyTable,
handleCopyDatabaseName,
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: visibleSidebarTreeData,
treeViewportWidth,
treeHeight,
isV2Ui,
isV2CommandSearchOpen,
connections,
connectionIds,
selectedKeys,
selectedNodesRef,
activeContext,
activeTab,
recentSqlLogs,
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(6, 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,
openSchemaVisibilitySettings,
resolveMessagePublishTarget,
addSqlLog,
handleV2TableContextMenuAction,
handleV2TableGroupContextMenuAction,
handleV2DatabaseContextMenuAction,
handleV2ConnectionContextMenuAction,
handleV2ConnectionGroupContextMenuAction,
});
refreshV2TableContextMenuStatsRef.current = refreshV2TableContextMenuStats;
const getV2TreeMetaTextRef = useRef(getV2TreeMetaText);
getV2TreeMetaTextRef.current = getV2TreeMetaText;
const toggleSidebarTablePinnedRef = useRef(toggleSidebarTablePinned);
toggleSidebarTablePinnedRef.current = toggleSidebarTablePinned;
const renderV2TreeTitle = useCallback((node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({
node,
hoverTitle,
statusBadge,
getV2TreeMetaText: getV2TreeMetaTextRef.current,
sidebarTableMetadataFields,
toggleSidebarTablePinned: toggleSidebarTablePinnedRef.current,
snapshotTreeSelectionBeforeDrag,
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
}), [
restoreTreeSelectionAfterDrag,
setIsTreeDragging,
sidebarTableMetadataFields,
snapshotTreeSelectionBeforeDrag,
treeDragSelectSuppressUntilRef,
]);
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,
shouldHideSchemaPrefix,
openSchemaVisibilitySettings,
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,
handleCopyTable,
handleCopyStructure,
handleExport,
setRenameTableTarget,
renameTableForm,
setIsRenameTableModalOpen,
handleTableDataDangerAction,
handleDeleteTable,
openExportDialog,
isSavedQueryUnmatched,
connections,
handleRebindSavedQuery,
openRenameSavedQueryModal,
handleRevealSavedQueryInFolder,
resolveSavedQueryDisplayName,
deleteQuery,
savedQueryGroups,
openSavedQueryGroupModal,
deleteSavedQueryGroup,
moveSavedQueryToGroup,
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 getTagParentId = (tagId: unknown): string | null => {
const tag = connectionTags.find((candidate) => candidate.id === String(tagId || '').trim());
const parentTagId = String(tag?.parentTagId || '').trim();
return parentTagId || null;
};
const getConnectionParentTagId = (connectionId: unknown): string | null => (
connectionTags.find((tag) => tag.connectionIds.includes(String(connectionId || '').trim()))?.id || null
);
const getNodeParentTagId = (node: any): string | null => {
if (node?.type === 'tag') return getTagParentId(node?.dataRef?.id);
if (node?.type === 'connection') return getConnectionParentTagId(node?.key);
return null;
};
const getNodeOrderToken = (node: any): string | null => {
if (node?.type === 'tag') {
const tagId = String(node?.dataRef?.id || '').trim();
return tagId ? buildSidebarRootTagToken(tagId) : null;
}
if (node?.type === 'connection') {
const connectionId = String(node?.key || '').trim();
return connectionId ? buildSidebarRootConnectionToken(connectionId) : null;
}
return null;
};
const allowSidebarTreeDrop = ({ dragNode, dropNode, dropPosition }: any): boolean => {
if (!dragNode || !dropNode) return false;
if ((dragNode.type !== 'tag' && dragNode.type !== 'connection') || (dropNode.type !== 'tag' && dropNode.type !== 'connection')) {
return false;
}
// Connections cannot contain tree items. A group can contain a group only
// when the pointer lands on its content, not on its before/after gap.
const droppingIntoTag = dropNode.type === 'tag' && Number(dropPosition) === 0;
if (dropNode.type === 'connection' && Number(dropPosition) === 0) return false;
if (dragNode.type !== 'tag') return String(dragNode.key) !== String(dropNode.key);
const dragTagId = String(dragNode?.dataRef?.id || '').trim();
const targetParentTagId = droppingIntoTag
? String(dropNode?.dataRef?.id || '').trim() || null
: getNodeParentTagId(dropNode);
return !!dragTagId && !isConnectionTagDescendant(dragTagId, targetParentTagId, connectionTags);
};
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);
if (!dragNode || !dropNode) return;
const droppingIntoTag = dropNode.type === 'tag' && (
info?.dropToGap === false || (info?.dropToGap === undefined && dropPosition === 0)
);
const targetParentTagId = droppingIntoTag
? String(dropNode?.dataRef?.id || '').trim() || null
: getNodeParentTagId(dropNode);
const targetToken = droppingIntoTag ? null : getNodeOrderToken(dropNode);
const targetInsertBefore = droppingIntoTag ? false : insertBefore;
if (dragNode.type === 'tag') {
const dragTagId = String(dragNode?.dataRef?.id || '').trim();
if (!dragTagId || isConnectionTagDescendant(dragTagId, targetParentTagId, connectionTags)) return;
moveConnectionTag(dragTagId, targetParentTagId, targetToken, targetInsertBefore);
return;
}
if (dragNode.type === 'connection') {
const connectionId = String(dragNode.key || '').trim();
if (!connectionId || connectionId === String(dropNode.key || '')) return;
moveConnectionToTag(connectionId, targetParentTagId, targetToken, targetInsertBefore);
}
};
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 v2DataImportLabel = t('sidebar.action.data_import');
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 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 handleOpenDataImportWorkbench = useCallback(() => {
const node = selectedNodesRef.current[0];
const activeTab = tabs.find((tab) => tab.id === activeTabId);
const nodeConnectionId = String(
node?.dataRef?.id || (node?.type === 'connection' ? node?.key : '') || '',
).trim();
const connectionId = nodeConnectionId || String(activeContext?.connectionId || '').trim();
const dbName = String(
node?.type === 'database'
? (node?.dataRef?.dbName || node?.title || '')
: node?.dataRef?.dbName || activeContext?.dbName || '',
).trim();
const tableName = String(
node?.type === 'table'
? (node?.dataRef?.tableName || node?.title || '')
: !node && activeTab?.type === 'table'
? activeTab.tableName || ''
: '',
).trim();
const mode = node?.type === 'database' ? 'database' : 'table';
const existingImportTab = tabs.find((tab) => tab.id === DATA_IMPORT_WORKBENCH_TAB_ID);
addTab(resolveDataImportWorkbenchLaunchTab(
existingImportTab,
{ connectionId, dbName, tableName, mode },
));
}, [activeContext?.connectionId, activeContext?.dbName, activeTabId, addTab, tabs]);
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)),
onRemoveRecentItem: (item: V2CommandSearchItem) => {
if (item.kind === 'recent') hideSqlLogFromRecent(item.logId);
},
onClearRecentItems: clearRecentSqlLogs,
onTogglePersistentFilter: toggleV2CommandSearchPersistentFilter,
onResetFilter: resetV2SidebarFilter,
},
};
// V2 Connection Rail 子组件 props从原 renderV2ConnectionRail 抽出,保留所有原行为)
const v2ConnectionRailProps = {
labels: {
railSystemActions: v2RailSystemActionsLabel,
railObjectActions: v2RailObjectActionsLabel,
newGroup: v2NewGroupLabel,
batchTables: v2BatchTablesLabel,
batchDatabases: v2BatchDatabasesLabel,
dataImport: v2DataImportLabel,
openExternalSqlFile: v2OpenExternalSqlFileLabel,
locateCurrentTable: v2LocateCurrentTableLabel,
locateCurrentTableUnavailable: v2LocateCurrentTableUnavailableLabel,
aiAssistant: v2AiAssistantLabel,
settings: v2SettingsLabel,
},
handlers: {
openCreateTagModal: () => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); },
openBatchTableExport: openBatchTableWorkbench,
openBatchDatabaseExport: openBatchDatabaseWorkbench,
openDataImport: handleOpenDataImportWorkbench,
openExternalSqlFile: handleOpenSQLFileFromToolbar,
locateActiveTab: handleLocateActiveTabInSidebar,
toggleAI: onToggleAI ?? (() => {}),
openSettings: onOpenSettings ?? (() => {}),
},
canLocateActiveTab,
sidebarExpandAction: onExpandSidebar && expandSidebarLabel ? {
label: expandSidebarLabel,
onClick: onExpandSidebar,
buttonRef: expandSidebarButtonRef,
} : undefined,
workbenchActions: (
<>
<SlowQueryRailButton
className="gn-v2-rail-tool gn-v2-rail-sql-analysis-button"
tooltipPlacement="right"
/>
<SqlAuditRailButton
className="gn-v2-rail-tool gn-v2-rail-sql-audit-button"
tooltipPlacement="right"
/>
</>
),
};
return (
<div className={isV2Ui ? 'gn-v2-sidebar-redesign' : undefined} style={{ display: 'flex', height: '100%', minHeight: 0 }}>
{exportProgressModal}
{isV2Ui && <SidebarConnectionRail {...v2ConnectionRailProps} />}
<div
id={isV2Ui ? 'gonavi-sidebar-tree-panel' : undefined}
className={isV2Ui ? 'gn-v2-object-explorer' : undefined}
data-sidebar-tree-panel={isV2Ui ? 'true' : undefined}
aria-hidden={isV2Ui ? isTreePanelCollapsed : undefined}
style={{ display: isV2Ui && isTreePanelCollapsed ? 'none' : '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) {
return;
}
const selectedDatabase = resolveV2SelectedDatabaseName({
activeConnectionId: activeConnection.id,
activeContextConnectionId: activeContext?.connectionId,
activeContextDbName: activeContext?.dbName,
});
if (selectedDatabase) {
handleV2DatabaseContextMenuAction(getDatabaseNodeRef(activeConnection, selectedDatabase), 'new-query');
return;
}
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>
{onCollapseSidebar && collapseSidebarLabel && (
<Tooltip title={collapseSidebarLabel} placement="bottom" mouseEnterDelay={0.35}>
<Button
ref={collapseSidebarButtonRef}
size="small"
type="text"
className="gonavi-sidebar-collapse-trigger"
data-sidebar-collapse-trigger="true"
data-sidebar-toggle-placement="explorer-header"
aria-label={collapseSidebarLabel}
aria-controls="gonavi-sidebar-tree-panel"
aria-expanded={true}
icon={<MenuFoldOutlined />}
onClick={onCollapseSidebar}
/>
</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={openBatchTableWorkbench}
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={openBatchDatabaseWorkbench}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={v2DataImportLabel}>
<Button
size="small"
type="text"
icon={<ImportOutlined />}
aria-label={v2DataImportLabel}
data-sidebar-data-import-action="true"
onClick={handleOpenDataImportWorkbench}
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'}-${sidebarObjectVisibilitySignature}`}
ref={treeRef}
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
allowDrop={allowSidebarTreeDrop}
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>
</div>
<SidebarSearchPanel {...v2CommandSearchPanelProps} />
{contextMenu?.kind && typeof document !== 'undefined' && createPortal(
<div
ref={contextMenuPortalRef}
className={`gn-v2-sidebar-context-menu-portal ${contextMenu.rootClassName || ''}`}
data-gonavi-close-shortcut-guard="true"
data-gonavi-close-shortcut-blocks-background="true"
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: APP_POPUP_Z_INDEX,
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}
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}
/>
<SavedQueryGroupModal
open={isSavedQueryGroupModalOpen}
groups={savedQueryGroups}
savedQueries={savedQueries}
target={savedQueryGroupTarget}
initialParentGroupId={savedQueryGroupInitialParentId}
modalPanelStyle={modalPanelStyle}
modalSectionStyle={modalSectionStyle}
modalScrollSectionStyle={modalScrollSectionStyle}
renderModalTitle={renderSidebarModalTitle}
onClose={closeSavedQueryGroupModal}
onSave={handleSaveSavedQueryGroup}
/>
<Modal
title={renderSidebarModalTitle(
<FolderOpenOutlined />,
t('sidebar.schema_visibility.title', { database: schemaVisibilityTarget?.dbName || '' }),
t('sidebar.schema_visibility.description'),
)}
open={Boolean(schemaVisibilityTarget)}
centered
width={560}
okText={t('common.save')}
confirmLoading={isSavingSchemaVisibility}
styles={{
content: modalPanelStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 },
}}
onOk={() => void handleSaveSchemaVisibility()}
onCancel={() => {
setSchemaVisibilityTarget(null);
schemaVisibilityForm.resetFields();
}}
>
<Form form={schemaVisibilityForm} layout="vertical">
<div style={modalSectionStyle}>
<Form.Item
name="mode"
label={t('sidebar.schema_visibility.field.mode')}
style={{ marginBottom: 14 }}
>
<Radio.Group optionType="button" buttonStyle="solid">
<Radio.Button value="include">
{t('sidebar.schema_visibility.mode.include')}
</Radio.Button>
<Radio.Button value="exclude">
{t('sidebar.schema_visibility.mode.exclude')}
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
name="schemas"
label={t('sidebar.schema_visibility.field.schemas')}
help={t('sidebar.schema_visibility.field.schemas_help')}
style={{ marginBottom: 12 }}
>
<Select
mode="tags"
allowClear
tokenSeparators={[',', ';', '', '']}
placeholder={t('sidebar.schema_visibility.field.schemas_placeholder')}
options={(schemaVisibilityTarget?.availableSchemas || []).map((schema) => ({
label: schema,
value: schema,
}))}
/>
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={modalHintTextStyle}>
{t('sidebar.schema_visibility.notice')}
</span>
<Button
type="link"
size="small"
onClick={() => schemaVisibilityForm.setFieldsValue({ schemas: [] })}
>
{t('sidebar.schema_visibility.action.show_all')}
</Button>
</div>
</div>
</Form>
</Modal>
<ExternalSQLFileModal {...externalSQLFileModalProps} />
<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;