Files
MyGoNavi/frontend/src/components/Sidebar.tsx
2026-06-19 18:08:54 +08:00

4882 lines
204 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 Modal from './common/ResizableDraggableModal';
import SidebarConnectionRail from './sidebar/SidebarConnectionRail';
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
import {
buildDuckDBMacroDDL,
buildSidebarTableStatusSQL,
escapeSQLLiteral,
extractSqlServerDefinitionRows,
getCaseInsensitiveRawValue,
getCaseInsensitiveValue,
getMetadataDialect,
getSidebarTableDisplayName,
shouldHideSchemaPrefix,
splitQualifiedName,
} from './sidebar/sidebarMetadataLoaders';
import {
useSidebarBatchExport,
} from './sidebar/useSidebarBatchExport';
import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals';
import { SidebarEntityModals } from './sidebar/SidebarEntityModals';
import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle';
import {
useSidebarV2ContextMenu,
type SidebarContextMenuState,
} from './sidebar/useSidebarV2ContextMenu';
import {
normalizeDriverType,
useSidebarTreeLoaders,
} from './sidebar/useSidebarTreeLoaders';
export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders';
import {
ExternalSQLFileModal,
SQLFileExecutionModal,
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,
isV2SidebarObjectNode,
resolveV2ObjectGroupTitle,
resolveSidebarTableNameForCopy,
parseV2CommandSearchQuery,
type V2ExplorerFilter,
type V2CommandSearchMode,
type V2CommandSearchQuery,
} from './sidebar/sidebarHelpers';
// 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容
export {
V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID,
formatSidebarRowCount,
hasSidebarLazyChildren,
shouldClearSidebarActiveContextOnEmptySelect,
shouldLoadSidebarNodeOnExpand,
getV2RailConnectionGroupBadgeText,
isV2SidebarObjectNode,
resolveV2ObjectGroupTitle,
resolveSidebarTableNameForCopy,
parseV2CommandSearchQuery,
} from './sidebar/sidebarHelpers';
import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react';
import { createPortal } from 'react-dom';
import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Switch } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
EyeOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
ExportOutlined,
FolderAddOutlined,
SaveOutlined,
EditOutlined,
SearchOutlined,
KeyOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
FunctionOutlined,
LinkOutlined,
FileAddOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
DeleteOutlined,
DisconnectOutlined,
CloudOutlined,
CheckSquareOutlined,
CodeOutlined,
TagOutlined,
CheckOutlined,
FilterOutlined,
DashboardOutlined,
WarningOutlined,
ClockCircleOutlined,
RobotOutlined,
AimOutlined,
MoreOutlined,
ToolOutlined,
SettingOutlined,
BarsOutlined
} from '@ant-design/icons';
import {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
resolveSidebarRootOrderTokens,
useStore,
} from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import FindInDatabaseModal from './FindInDatabaseModal';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import {
resolveSidebarRuntimeDatabase,
type SidebarViewMetadataEntry,
} from '../utils/sidebarMetadata';
import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql';
import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay';
import {
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
type SidebarLocateTreeNodeLike,
} from '../utils/sidebarLocate';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import {
buildBatchDatabaseExportWorkbenchTab,
buildBatchTableExportWorkbenchTab,
buildTableExportTab,
} from '../utils/tableExportTab';
import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import { getCurrentLanguage, t } from '../i18n';
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition';
import JVMModeBadge from './jvm/JVMModeBadge';
import MessagePublishModal from './MessagePublishModal';
import {
SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT,
SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH,
normalizeMySQLViewDDLForEditing,
resolveSidebarContextMenuPosition,
resolveSidebarObjectDragText,
type SearchScope,
} from './sidebarCoreUtils';
export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils';
export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils';
import {
V2DatabaseContextMenuView,
V2ConnectionGroupContextMenuView,
V2ConnectionContextMenuView,
V2SchemaContextMenuView,
V2TableContextMenuView,
V2TableGroupContextMenuView,
type V2DatabaseContextMenuActionKey,
type V2ConnectionGroupContextMenuActionKey,
type V2ConnectionContextMenuActionKey,
type V2SchemaContextMenuActionKey,
type V2TableContextMenuActionKey,
type V2TableContextMenuStats,
type V2TableGroupContextMenuActionKey,
} from './V2TableContextMenu';
import {
V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE,
buildSidebarTableChildrenForUi,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
isSidebarTablePinned,
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarNodeConnectionId,
resolveSidebarTagDropInsertBefore,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
type SidebarTreeNode as TreeNode,
type V2CommandSearchItem,
type V2RailConnectionGroup,
} from './sidebarV2Utils';
export {
buildSidebarTableChildrenForUi,
buildV2RailConnectionGroups,
buildV2SidebarTableSectionedChildren,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
isSidebarTablePinned,
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarNodeConnectionId,
resolveSidebarTagDropInsertBefore,
resolveV2ActiveConnectionId,
resolveV2CommandSearchPersistentFilter,
shouldSkipSidebarLoadOnExpandWhileDragging,
shouldSkipSidebarSelectWhileDragging,
shouldCloseV2CommandSearchOnGlobalKey,
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
};
export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils';
const { Search } = Input;
const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;
const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
// 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: 'routines', labelKey: 'sidebar.command_search.object_kind.routines' },
{ key: 'events', labelKey: 'sidebar.command_search.object_kind.events' },
];
type SidebarMessagePublishTarget = {
connection: SavedConnection;
executionDbName: string;
destination: string;
};
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
if (!conn) return '';
return JSON.stringify({
config: conn.config || {},
includeDatabases: conn.includeDatabases || [],
includeRedisDatabases: conn.includeRedisDatabases || [],
});
};
const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
const text = String(key);
return text === connectionId || text.startsWith(`${connectionId}-`);
};
const isPostgresSchemaDialect = (dialect: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect))
);
const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; labelKey: string }> = [
{ value: 'smart', labelKey: 'sidebar.command_search.scope.smart' },
{ value: 'object', labelKey: 'sidebar.command_search.scope.object' },
{ value: 'database', labelKey: 'sidebar.command_search.scope.database' },
{ value: 'host', labelKey: 'sidebar.command_search.scope.host' },
{ value: 'tag', labelKey: 'sidebar.command_search.scope.tag' },
];
const SEARCH_SCOPE_LABEL_KEY_MAP: Record<SearchScope, string> = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => {
acc[option.value] = option.labelKey;
return acc;
}, {} as Record<SearchScope, string>);
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
smart: <ThunderboltOutlined />,
object: <TableOutlined />,
database: <DatabaseOutlined />,
host: <CloudOutlined />,
tag: <TagOutlined />,
};
const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set<string>): boolean => (
query.bindingStatus === 'orphan' || !connectionIds.has(query.connectionId)
);
export const buildAllSavedQueriesTreeNode = (
savedQueries: SavedQuery[],
connections: SavedConnection[],
): TreeNode | null => {
if (savedQueries.length === 0) {
return null;
}
const connectionIds = new Set(connections.map((conn) => conn.id));
const unmatchedSavedQueries = savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds));
const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id));
const createQueryNode = (query: SavedQuery): TreeNode => ({
title: query.name || t('sidebar.tree.untitled_query'),
key: `all-saved-query-${query.id}`,
icon: <FileTextOutlined />,
type: 'saved-query',
dataRef: query,
isLeaf: true,
});
const buildDatabaseGroups = (queries: SavedQuery[], keyPrefix: string): TreeNode[] => {
const groupedByDatabase = new Map<string, SavedQuery[]>();
queries.forEach((query) => {
const dbName = String(query.dbName || '').trim() || t('sidebar.tree.default_database');
groupedByDatabase.set(dbName, [...(groupedByDatabase.get(dbName) || []), query]);
});
return Array.from(groupedByDatabase.entries()).map(([dbName, items]) => ({
title: dbName,
key: `${keyPrefix}-db-${encodeURIComponent(dbName)}`,
icon: <DatabaseOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: items.map(createQueryNode),
}));
};
const groupedByConnection = new Map<string, SavedQuery[]>();
savedQueries.forEach((query) => {
if (unmatchedIds.has(query.id)) {
return;
}
groupedByConnection.set(query.connectionId, [
...(groupedByConnection.get(query.connectionId) || []),
query,
]);
});
const children: TreeNode[] = [];
connections.forEach((conn) => {
const connectionQueries = groupedByConnection.get(conn.id);
if (!connectionQueries || connectionQueries.length === 0) {
return;
}
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
children.push({
title: conn.name || conn.id,
key: `all-saved-queries-connection-${conn.id}`,
icon: getDbIcon(iconType, iconColor, 22),
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: buildDatabaseGroups(connectionQueries, `all-saved-queries-connection-${conn.id}`),
});
});
if (unmatchedSavedQueries.length > 0) {
const groupedByOriginalConnection = new Map<string, SavedQuery[]>();
unmatchedSavedQueries.forEach((query) => {
const originalConnectionId = String(query.originalConnectionId || query.connectionId || t('sidebar.tree.unknown_connection')).trim() || t('sidebar.tree.unknown_connection');
groupedByOriginalConnection.set(originalConnectionId, [
...(groupedByOriginalConnection.get(originalConnectionId) || []),
query,
]);
});
children.push({
title: t('sidebar.tree.unmatched_saved_queries'),
key: 'all-saved-queries-unmatched',
icon: <WarningOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: Array.from(groupedByOriginalConnection.entries()).map(([connectionLabel, items]) => ({
title: connectionLabel,
key: `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`,
icon: <FolderOpenOutlined />,
type: 'saved-query-group',
selectable: false,
isLeaf: false,
children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`),
})),
});
}
return {
title: t('sidebar.tree.all_saved_queries'),
key: 'all-saved-queries',
icon: <FolderOpenOutlined />,
type: 'all-saved-queries',
isLeaf: false,
selectable: false,
children,
};
};
const Sidebar: React.FC<{
onCreateConnection?: () => void;
onEditConnection?: (conn: SavedConnection) => void;
onOpenTools?: () => void;
onOpenSettings?: () => void;
onToggleAI?: () => void;
onToggleLogPanel?: () => void;
sqlLogCount?: number;
uiVersion?: 'legacy' | 'v2';
onFocusCommandSearch?: () => void;
}> = React.memo(({
onCreateConnection,
onEditConnection,
onOpenTools,
onOpenSettings,
onToggleAI,
onToggleLogPanel,
sqlLogCount = 0,
uiVersion,
onFocusCommandSearch,
}) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
const saveQuery = useStore(state => state.saveQuery);
const deleteQuery = useStore(state => state.deleteQuery);
const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const connectionTags = useStore(state => state.connectionTags);
const sidebarRootOrder = useStore(state => state.sidebarRootOrder);
const addConnectionTag = useStore(state => state.addConnectionTag);
const updateConnectionTag = useStore(state => state.updateConnectionTag);
const removeConnectionTag = useStore(state => state.removeConnectionTag);
const moveConnectionToTag = useStore(state => state.moveConnectionToTag);
const reorderConnections = useStore(state => state.reorderConnections);
const reorderTags = useStore(state => state.reorderTags);
const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);
const closeTabsByConnection = useStore(state => state.closeTabsByConnection);
const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const activeContext = useStore(state => state.activeContext);
const tableAccessCount = useStore(state => state.tableAccessCount);
const tableSortPreference = useStore(state => state.tableSortPreference);
const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables);
const recordTableAccess = useStore(state => state.recordTableAccess);
const setTableSortPreference = useStore(state => state.setTableSortPreference);
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
const addSqlLog = useStore(state => state.addSqlLog);
const sqlLogs = useStore(state => state.sqlLogs) || [];
const shortcutOptions = useStore(state => state.shortcutOptions);
const languagePreference = useStore(state => state.languagePreference);
const setAppearance = useStore(state => state.setAppearance);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const addAIContext = useStore(state => state.addAIContext);
void languagePreference;
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const { 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 }),
[darkMode, disableLocalBackdropFilter, appearance.uiVersion],
);
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 [searchValue, setSearchValue] = useState(v2PersistedSidebarFilter);
const deferredSearchValue = useDeferredValue(searchValue);
const [searchScopes, setSearchScopes] = useState<SearchScope[]>(['smart']);
const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('all');
const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false);
const searchInputRef = useRef<any>(null);
const commandSearchInputRef = useRef<any>(null);
const [isV2CommandSearchOpen, setIsV2CommandSearchOpen] = useState(false);
const [v2CommandSearchValue, setV2CommandSearchValue] = useState('');
const deferredV2CommandSearchValue = useDeferredValue(v2CommandSearchValue);
const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const treeDragSelectSuppressUntilRef = useRef(0);
const treeDragSelectionSnapshotRef = useRef<{
selectedKeys: React.Key[];
selectedNodes: any[];
activeContext: { connectionId: string; dbName: string } | null;
}>({
selectedKeys: [],
selectedNodes: [],
activeContext: null,
});
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
const unmatchedSavedQueries = useMemo(
() => savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIdSet)),
[connectionIdSet, savedQueries],
);
const allSavedQueriesNode = useMemo<TreeNode | null>(() => {
return buildAllSavedQueriesTreeNode(savedQueries, connections);
}, [connections, savedQueries]);
const snapshotTreeSelectionBeforeDrag = useCallback(() => {
treeDragSelectionSnapshotRef.current = {
selectedKeys: [...selectedKeys],
selectedNodes: [...selectedNodesRef.current],
activeContext: activeContext ? { ...activeContext } : null,
};
}, [activeContext, selectedKeys]);
const restoreTreeSelectionAfterDrag = useCallback(() => {
const snapshot = treeDragSelectionSnapshotRef.current;
treeDragSelectSuppressUntilRef.current = Date.now() + 1000;
setSelectedKeys(snapshot.selectedKeys);
selectedNodesRef.current = snapshot.selectedNodes;
setActiveContext(snapshot.activeContext);
}, [setActiveContext]);
const openV2CommandSearch = useCallback(() => {
setIsV2CommandSearchOpen(true);
setV2CommandActiveIndex(0);
}, []);
const commitV2CommandSearchPersistentFilter = useCallback((value = v2CommandSearchValue) => {
if (!v2CommandSearchPersistentFilterEnabled) {
return;
}
const nextFilter = value.trim();
setSearchValue(nextFilter);
if (nextFilter !== v2PersistedSidebarFilter) {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}
}, [setAppearance, v2CommandSearchPersistentFilterEnabled, v2CommandSearchValue, v2PersistedSidebarFilter]);
const closeV2CommandSearch = useCallback(() => {
commitV2CommandSearchPersistentFilter();
setIsV2CommandSearchOpen(false);
setV2CommandSearchValue('');
setV2CommandActiveIndex(0);
}, [commitV2CommandSearchPersistentFilter]);
useEffect(() => {
setSearchValue(v2PersistedSidebarFilter);
}, [v2PersistedSidebarFilter]);
useEffect(() => {
if (!v2UseLegacySidebarFilter) {
return;
}
const nextFilter = searchValue.trim();
if (nextFilter !== v2PersistedSidebarFilter) {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}
}, [searchValue, setAppearance, v2PersistedSidebarFilter, v2UseLegacySidebarFilter]);
const handleV2CommandSearchValueChange = useCallback((value: string) => {
setV2CommandSearchValue(value);
}, []);
useEffect(() => {
if (!v2CommandSearchPersistentFilterEnabled) {
return;
}
if (!isV2CommandSearchOpen) {
return;
}
const nextFilter = resolveV2CommandSearchPersistentFilter({
commandSearchValue: deferredV2CommandSearchValue,
persistedFilter: v2PersistedSidebarFilter,
enabled: v2CommandSearchPersistentFilterEnabled,
isOpen: isV2CommandSearchOpen,
});
setSearchValue(nextFilter);
const timer = window.setTimeout(() => {
setAppearance({ v2SidebarPersistedFilter: nextFilter });
}, 160);
return () => window.clearTimeout(timer);
}, [deferredV2CommandSearchValue, isV2CommandSearchOpen, setAppearance, v2CommandSearchPersistentFilterEnabled, v2PersistedSidebarFilter]);
const toggleV2CommandSearchPersistentFilter = useCallback((enabled: boolean) => {
const nextFilter = enabled ? v2CommandSearchValue.trim() : '';
setSearchValue(nextFilter);
setAppearance({
v2CommandSearchPersistentFilterEnabled: enabled,
v2SidebarPersistedFilter: nextFilter,
});
message.success(
enabled
? t('sidebar.message.sidebar_filter_sync_enabled')
: t('sidebar.message.sidebar_filter_sync_disabled'),
);
}, [setAppearance, v2CommandSearchValue]);
const resetV2SidebarFilter = useCallback(() => {
setSearchValue('');
setAppearance({
v2CommandSearchPersistentFilterEnabled: false,
v2SidebarPersistedFilter: '',
});
message.success(t('sidebar.message.sidebar_filter_reset'));
}, [setAppearance]);
// Virtual Scroll State
const [treeHeight, setTreeHeight] = useState(500);
const [treeViewportWidth, setTreeViewportWidth] = useState(0);
const treeContainerRef = useRef<HTMLDivElement>(null);
const treeRef = useRef<any>(null);
const treeDataRef = useRef<TreeNode[]>([]);
const externalSQLDirectoryTreesRef = useRef<Record<string, ExternalSQLTreeEntry[]>>({});
const findTreeNodeByKeyRef = useRef<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>(() => null);
const expandConnectionFromRailRef = useRef<(connectionId: string) => void>(() => {});
useEffect(() => {
treeDataRef.current = treeData;
}, [treeData]);
useEffect(() => {
if (!treeContainerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
setTreeHeight(entry.contentRect.height);
setTreeViewportWidth(entry.contentRect.width);
}
});
resizeObserver.observe(treeContainerRef.current);
return () => resizeObserver.disconnect();
}, []);
useEffect(() => {
const handleFocusSidebarSearch = () => {
if (isV2Ui && !v2UseLegacySidebarFilter) {
openV2CommandSearch();
return;
}
const inputEl = searchInputRef.current?.input as HTMLInputElement | undefined;
if (!inputEl) {
return;
}
inputEl.focus();
inputEl.select();
};
window.addEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener);
return () => {
window.removeEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener);
};
}, [isV2Ui, openV2CommandSearch, v2UseLegacySidebarFilter]);
useEffect(() => {
if (!isV2CommandSearchOpen) return;
const timer = window.setTimeout(() => {
const inputEl = commandSearchInputRef.current?.input as HTMLInputElement | undefined;
inputEl?.focus();
inputEl?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [isV2CommandSearchOpen]);
useEffect(() => {
if (!isV2CommandSearchOpen) return;
const handleV2CommandSearchGlobalKeyDown = (event: KeyboardEvent) => {
if (!shouldCloseV2CommandSearchOnGlobalKey({ key: event.key, isOpen: isV2CommandSearchOpen })) {
return;
}
event.preventDefault();
event.stopPropagation();
closeV2CommandSearch();
};
window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true);
}, [closeV2CommandSearch, isV2CommandSearchOpen]);
// Connection Status State: key -> 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, 'success' | 'error'>>({});
const [isTreeDragging, setIsTreeDragging] = useState(false);
// Create Database Modal
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
const [createDbForm] = Form.useForm();
const [targetConnection, setTargetConnection] = useState<any>(null);
const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false);
const [createSchemaForm] = Form.useForm();
const [createSchemaTarget, setCreateSchemaTarget] = useState<any>(null);
const [isRenameSchemaModalOpen, setIsRenameSchemaModalOpen] = useState(false);
const [renameSchemaForm] = Form.useForm();
const [renameSchemaTarget, setRenameSchemaTarget] = useState<any>(null);
const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false);
const [renameDbForm] = Form.useForm();
const [renameDbTarget, setRenameDbTarget] = useState<any>(null);
const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false);
const [renameTableForm] = Form.useForm();
const [renameTableTarget, setRenameTableTarget] = useState<any>(null);
const [messagePublishTarget, setMessagePublishTarget] = useState<SidebarMessagePublishTarget | null>(null);
const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false);
const [renameViewForm] = Form.useForm();
const [renameViewTarget, setRenameViewTarget] = useState<any>(null);
const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false);
const [renameSavedQueryForm] = Form.useForm();
const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState<SavedQuery | null>(null);
// Connection Tag Modals
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
const [createTagForm] = Form.useForm();
const {
isBatchModalOpen,
setIsBatchModalOpen,
batchTables,
checkedTableKeys,
setCheckedTableKeys,
selectedConnection,
selectedDatabase,
availableDatabases,
batchFilterKeyword,
setBatchFilterKeyword,
batchFilterType,
setBatchFilterType,
batchSelectionScope,
setBatchSelectionScope,
filteredBatchObjects,
groupedBatchObjects,
selectionScopeTargetKeys,
isBatchDbModalOpen,
setIsBatchDbModalOpen,
batchDatabases,
checkedDbKeys,
setCheckedDbKeys,
selectedDbConnection,
handleExportDatabaseSQL,
handleExportSchemaSQL,
openBatchOperationModal,
openBatchTableExportWorkbench,
handleConnectionChange,
handleDatabaseChange,
handleBatchExport,
handleBatchClear,
handleCheckAll,
handleInvertSelection,
openBatchDatabaseModal,
openBatchDatabaseExportWorkbench,
handleDbConnectionChange,
handleBatchDbExport,
handleCheckAllDb,
handleInvertSelectionDb,
} = useSidebarBatchExport({
connections,
selectedNodesRef,
addTab,
addSqlLog,
});
// Find in Database Modal
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
useEffect(() => {
if (!autoFetchVisible) {
return;
}
expandedKeys.forEach(key => {
const node = findTreeNodeByKey(treeData, key);
if (node && node.type === 'database') {
loadTables(node);
}
});
}, [autoFetchVisible, savedQueries]);
useEffect(() => {
const previousSignatures = connectionReloadSignaturesRef.current;
const nextSignatures: Record<string, string> = {};
const staleConnectionIds = new Set<string>();
connections.forEach((conn) => {
const signature = buildConnectionReloadSignature(conn);
nextSignatures[conn.id] = signature;
if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) {
staleConnectionIds.add(conn.id);
}
});
connectionReloadSignaturesRef.current = nextSignatures;
if (staleConnectionIds.size > 0) {
const staleIds = Array.from(staleConnectionIds);
setLoadedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setExpandedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setConnectionStates((prev) => {
const next = { ...prev };
staleIds.forEach((id) => {
Object.keys(next).forEach((key) => {
if (isConnectionTreeKey(key, id)) {
delete next[key];
}
});
});
return next;
});
staleIds.forEach((id) => {
Array.from(loadingNodesRef.current).forEach((key) => {
if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) {
loadingNodesRef.current.delete(key);
}
});
});
}
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
// We need to recursively extract connections from old tag structures
// so if a user expands a connection that was tagged, the state remains
const recurseCollect = (nodes: TreeNode[]) => {
nodes.forEach((node) => {
if (node.type === 'tag') {
if (node.children) recurseCollect(node.children);
} else if (node.type === 'connection') {
prevMap.set(String(node.key), node);
}
});
};
recurseCollect(prev);
const buildConnectionNode = (conn: SavedConnection): TreeNode => {
const existing = prevMap.get(conn.id);
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
return {
title: conn.name,
key: conn.id,
icon: getDbIcon(iconType, iconColor, 22),
type: 'connection',
dataRef: conn,
isLeaf: false,
children: preserveChildren ? existing.children : undefined,
} as TreeNode;
};
const taggedConnIds = new Set<string>();
const tagNodesById = new Map<string, TreeNode>();
connectionTags.forEach((tag) => {
tag.connectionIds.forEach(id => taggedConnIds.add(id));
tagNodesById.set(tag.id, {
title: tag.name,
key: `tag-${tag.id}`,
icon: (
<span
className="gn-v2-tree-folder-icon"
data-sidebar-tree-folder-icon="true"
>
<FolderOutlined />
</span>
),
type: 'tag',
dataRef: tag,
isLeaf: false,
children: tag.connectionIds
.map(cid => connections.find(c => c.id === cid))
.filter(Boolean)
.map(conn => buildConnectionNode(conn!)),
} as TreeNode);
});
const ungroupedNodesById = new Map<string, TreeNode>();
connections
.filter(c => !taggedConnIds.has(c.id))
.forEach((conn) => {
ungroupedNodesById.set(conn.id, buildConnectionNode(conn));
});
const orderedRootTokens = resolveSidebarRootOrderTokens(
sidebarRootOrder,
connectionTags,
connections,
);
const orderedNodes: TreeNode[] = [];
orderedRootTokens.forEach((token) => {
if (token.startsWith('tag:')) {
const tagNode = tagNodesById.get(token.slice('tag:'.length));
if (!tagNode) return;
orderedNodes.push(tagNode);
tagNodesById.delete(token.slice('tag:'.length));
return;
}
if (token.startsWith('connection:')) {
const connectionNode = ungroupedNodesById.get(token.slice('connection:'.length));
if (!connectionNode) return;
orderedNodes.push(connectionNode);
ungroupedNodesById.delete(token.slice('connection:'.length));
}
});
orderedNodes.push(...Array.from(tagNodesById.values()));
orderedNodes.push(...Array.from(ungroupedNodesById.values()));
if (allSavedQueriesNode) {
orderedNodes.push(allSavedQueriesNode);
}
const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root');
return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes;
});
}, [connections, connectionTags, sidebarRootOrder, allSavedQueriesNode]);
const handleDuplicateConnection = async (conn: SavedConnection) => {
if (!conn?.id) return;
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DuplicateConnection !== 'function') {
message.error(t('connection.sidebar.duplicate.backendUnavailable'));
return;
}
try {
const duplicatedConnection = await backendApp.DuplicateConnection(conn.id);
if (!duplicatedConnection) {
throw new Error(t('connection.sidebar.duplicate.noResult'));
}
addConnection(duplicatedConnection);
message.success(t('connection.sidebar.duplicate.success', {
name: duplicatedConnection.name,
}));
} catch (error: any) {
message.error(error?.message || t('connection.sidebar.duplicate.failureFallback'));
}
};
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
return list.map(node => {
if (node.key === key) {
return { ...node, children };
}
if (node.children) {
return { ...node, children: updateTreeData(node.children, key, children) };
}
return node;
});
};
const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
for (const node of nodes) {
if (node.key === targetKey) {
return node;
}
if (node.children) {
const child = findTreeNodeByKey(node.children, targetKey);
if (child) {
return child;
}
}
}
return null;
};
findTreeNodeByKeyRef.current = findTreeNodeByKey;
const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
const nextTreeData = updateTreeData(treeDataRef.current, key, children);
treeDataRef.current = nextTreeData;
setTreeData(nextTreeData);
return nextTreeData;
};
const 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,
sqlFileExecutionModalProps,
} = useSidebarExternalSqlWorkflow({
connections,
externalSQLDirectories,
activeTab,
connectionIds,
selectedNodesRef,
addTab,
saveExternalSQLDirectory,
deleteExternalSQLDirectory,
refreshGlobalExternalSQLRootNode,
setExpandedKeys,
setAutoExpandParent,
getActiveContext: () => useStore.getState().activeContext,
});
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
if (!node) return null;
if (node.type === 'database') {
return {
connectionId: String(node?.dataRef?.id || '').trim(),
dbName: String(node?.dataRef?.dbName || '').trim(),
dbNodeKey: String(node.key || '').trim(),
};
}
if (
node.type === 'external-sql-root'
|| node.type === 'external-sql-directory'
|| node.type === 'external-sql-folder'
|| node.type === 'external-sql-file'
) {
return {
connectionId: String(node?.dataRef?.connectionId || '').trim(),
dbName: String(node?.dataRef?.dbName || '').trim(),
dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
};
}
return null;
};
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
const waitForSidebarLoadKey = async (loadKey: string): Promise<boolean> => {
for (let attempt = 0; attempt < SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS && loadingNodesRef.current.has(loadKey); attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS));
}
return !loadingNodesRef.current.has(loadKey);
};
const locateObjectInSidebar = async (detail: unknown) => {
const request = normalizeSidebarLocateObjectRequest(detail);
if (!request) {
message.warning(t('sidebar.message.locate_current_table_unavailable'));
return;
}
if (request.objectGroup === 'externalSqlFiles') {
await refreshGlobalExternalSQLRootNode(false);
const target = resolveSidebarLocateTarget(request, { groupBySchema: false });
const path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
if (!path) {
message.warning(t('sidebar.message.locate_external_sql_file_not_found', { path: request.filePath }));
return;
}
const targetKey = path[path.length - 1];
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
setSearchValue('');
mergeExpandedTreeKeys(path.slice(0, -1));
setSelectedKeys([targetKey]);
selectedNodesRef.current = targetNode ? [targetNode] : [];
const connectionId = String(request.connectionId || activeContext?.connectionId || activeTab?.connectionId || '').trim();
const dbName = String(request.dbName || activeContext?.dbName || activeTab?.dbName || '').trim();
if (connectionId) {
setActiveContext({ connectionId, dbName });
}
scrollSidebarTreeToKey(targetKey);
return;
}
const conn = connections.find(item => item.id === request.connectionId);
if (!conn) {
message.warning(t('sidebar.message.locate_connection_not_found_for_object'));
return;
}
const target = resolveSidebarLocateTarget(request, {
groupBySchema: shouldHideSchemaPrefix(conn),
});
const objectLabel = request.objectGroup === 'materializedViews'
? t('sidebar.locate.object.materialized_view')
: request.objectGroup === 'views'
? t('sidebar.locate.object.view')
: request.objectGroup === 'triggers'
? t('sidebar.locate.object.trigger')
: request.objectGroup === 'routines'
? t('sidebar.locate.object.routine')
: t('sidebar.locate.object.table');
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
const dbLoadKey = `dbs-${request.connectionId}`;
const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`;
if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) {
const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey);
if (!connectionNode) {
message.warning(t('sidebar.message.locate_connection_not_in_tree'));
return;
}
if (loadingNodesRef.current.has(dbLoadKey)) {
const loaded = await waitForSidebarLoadKey(dbLoadKey);
if (!loaded) {
message.info(t('sidebar.message.locate_database_loading', { database: request.dbName }));
return;
}
} else {
await loadDatabases(connectionNode);
}
}
const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey);
if (!dbNode) {
message.warning(t('sidebar.message.locate_database_not_found', { database: request.dbName }));
return;
}
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
if (!path) {
if (loadingNodesRef.current.has(tableLoadKey)) {
const loaded = await waitForSidebarLoadKey(tableLoadKey);
if (!loaded) {
message.info(t('sidebar.message.locate_object_loading', {
object: objectLabel,
database: request.dbName,
}));
return;
}
} else {
await loadTables(dbNode);
}
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
}
if (!path) {
message.warning(t('sidebar.message.locate_object_not_found', {
object: objectLabel,
name: request.tableName,
}));
return;
}
const targetKey = path[path.length - 1];
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
setSearchValue('');
mergeExpandedTreeKeys(path.slice(0, -1));
setSelectedKeys([targetKey]);
selectedNodesRef.current = targetNode ? [targetNode] : [];
setActiveContext({ connectionId: request.connectionId, dbName: request.dbName });
scrollSidebarTreeToKey(targetKey);
};
const handleLocateActiveTabInSidebar = () => {
if (!activeTabLocateRequest) {
message.warning(t('sidebar.message.locate_current_table_unavailable'));
return;
}
void locateObjectInSidebar(activeTabLocateRequest);
};
useEffect(() => {
locateObjectInSidebarRef.current = locateObjectInSidebar;
});
useEffect(() => {
const handleLocateSidebarObject = (event: Event) => {
void locateObjectInSidebarRef.current((event as CustomEvent).detail);
};
window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
return () => {
window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
};
}, []);
useEffect(() => {
const handleSidebarTablePinChanged = (event: Event) => {
const detail = (event as CustomEvent).detail || {};
const connectionId = String(detail.connectionId || '').trim();
const dbName = String(detail.dbName || '').trim();
if (!connectionId || !dbName) return;
const dbNode = findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`);
if (dbNode) {
void loadTables(dbNode);
}
};
window.addEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener);
return () => {
window.removeEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener);
};
}, []);
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'unmatched-saved-queries') return;
if (hasSidebarLazyChildren(children)) return;
if (type === 'connection') {
await loadDatabases({ key, dataRef });
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
await loadJVMResources({ key, dataRef });
} else if (type === 'database') {
await loadTables({ key, dataRef });
} else if (type === 'external-sql-root') {
await refreshGlobalExternalSQLRootNode(false);
} else if (type === 'table') {
// Expand table to show object categories
const conn = dataRef;
const folders: TreeNode[] = [
{
title: t('sidebar.table_folder.columns'),
key: `${key}-columns`,
icon: <UnorderedListOutlined />,
type: 'folder-columns',
isLeaf: true,
dataRef: conn
},
{
title: t('sidebar.table_folder.indexes'),
key: `${key}-indexes`,
icon: <KeyOutlined style={{ transform: 'rotate(45deg)' }} />,
type: 'folder-indexes',
isLeaf: true,
dataRef: conn
},
{
title: t('sidebar.table_folder.foreign_keys'),
key: `${key}-fks`,
icon: <LinkOutlined />,
type: 'folder-fks',
isLeaf: true,
dataRef: conn
},
{
title: t('sidebar.table_folder.triggers'),
key: `${key}-triggers`,
icon: <ThunderboltOutlined />,
type: 'folder-triggers',
isLeaf: true,
dataRef: conn
}
];
replaceTreeNodeChildren(key, folders);
}
};
const isStructureOnlyDbType = (connectionId: string): boolean => {
const conn = connections.find(c => c.id === connectionId);
if (!conn) return false;
const dbType = resolveDataSourceType(conn.config);
return dbType === 'elasticsearch' || dbType === 'mongodb' || dbType === 'redis' || dbType === 'iotdb';
};
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
const { tableName, dbName, id } = node.dataRef;
const forceReadOnly = readOnly || isStructureOnlyDbType(id);
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;
if (isStructureOnlyDbType(id)) {
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 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 === '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);
}
};
const onExpand = (newExpandedKeys: React.Key[], info?: any) => {
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 === '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,
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 === '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 handleCopyStructure = async (node: any) => {
const { config, dbName, tableName } = node.dataRef;
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success(t('table_overview.message.copy_structure_success'));
} else {
message.error(res.message);
}
};
const resolveCopyObjectNameLabel = (node: any): string => {
if (node?.type === 'view') return t('sidebar.copy_object_name.label.view');
if (node?.type === 'materialized-view') return t('sidebar.copy_object_name.label.materialized_view');
if (node?.type === 'db-event') return t('sidebar.copy_object_name.label.event');
return t('sidebar.copy_object_name.label.table');
};
const handleCopyTableName = async (node: any) => {
const objectName = resolveSidebarTableNameForCopy(node);
const label = resolveCopyObjectNameLabel(node);
if (!objectName) {
message.warning(t('sidebar.copy_object_name.empty', { label }));
return;
}
try {
await navigator.clipboard.writeText(objectName);
message.success(t('sidebar.copy_object_name.copied', { label }));
} catch (e: any) {
message.error(t('sidebar.copy_object_name.failed', { label, error: e?.message || String(e) }));
}
};
const handleExport = async (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => {
const { config, dbName, tableName } = node.dataRef;
const rowCount = Number(node?.dataRef?.rowCount);
const totalRowsKnown = Number.isFinite(rowCount) && rowCount > 0;
await runExportWithProgress({
title: `导出 ${tableName}`,
targetName: tableName,
format: options.format,
totalRows: totalRowsKnown ? rowCount : undefined,
run: (jobId) => ExportTableWithOptions(
buildRpcConnectionConfig(config) as any,
dbName,
tableName,
{
...options,
jobId,
totalRowsHint: totalRowsKnown ? rowCount : 0,
totalRowsKnown,
} as any,
),
});
};
const openExportDialog = async (node: any) => {
const tableName = String(node?.dataRef?.tableName || node?.title || '').trim();
if (!tableName) {
message.warning('未识别到表名,无法导出');
return;
}
const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();
const dbName = String(node?.dataRef?.dbName || '').trim();
addTab(buildTableExportTab({
connectionId,
dbName,
tableName,
title: `导出 ${tableName}`,
objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'),
schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined,
sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined,
rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) > 0
? { all: Math.trunc(Number(node.dataRef.rowCount)) }
: undefined,
}));
};
const handleCopyTableAsInsert = async (node: any) => {
await handleExport(node, { format: 'sql' });
};
const openTableDdlInDesigner = (node: any) => {
openDesign(node, 'ddl', true);
};
const openTableInERView = (node: any) => {
onDoubleClick(null, node);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', {
detail: {
connectionId: node.dataRef?.id,
dbName: node.dataRef?.dbName,
tableName: node.dataRef?.tableName,
viewMode: 'er',
},
}));
}, 0);
};
const injectTablePromptToAI = async (node: any, promptKind: 'explain' | 'query') => {
const conn = node.dataRef;
const tableName = String(conn?.tableName || node?.title || '').trim();
if (!conn?.id || !conn?.dbName || !tableName) {
message.warning('当前表缺少连接上下文,无法发送给 AI');
return;
}
let ddl = '';
try {
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, conn.dbName, tableName);
if (res.success) {
ddl = String(res.data || '').trim();
addAIContext(conn.id, { dbName: conn.dbName, tableName, ddl });
}
} catch {
// AI 入口仍可基于表名工作DDL 获取失败不阻断打开面板。
}
const prompt = promptKind === 'explain'
? [
`请解释数据表 ${conn.dbName}.${tableName} 的结构和业务含义。`,
'重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。',
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n')
: [
`请基于数据表 ${conn.dbName}.${tableName} 生成 3 条常用查询 SQL。`,
'要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。',
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n');
const wasClosed = !useStore.getState().aiPanelVisible;
if (wasClosed) setAIPanelVisible(true);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
};
const refreshDatabaseNode = async (dbNodeKey: string) => {
if (!dbNodeKey) {
return;
}
const dbNode = findTreeNodeByKey(treeData, dbNodeKey);
if (dbNode && dbNode.type === 'database') {
await loadTables(dbNode);
}
};
const handleCreateDatabase = async () => {
try {
const values = await createDbForm.validateFields();
const conn = targetConnection.dataRef;
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name);
if (res.success) {
message.success("数据库创建成功");
setIsCreateDbModalOpen(false);
createDbForm.resetFields();
// Refresh node
loadDatabases(targetConnection);
} else {
message.error("创建失败: " + res.message);
}
} catch (e) {
// Validate failed
}
};
const openCreateSchemaModal = (node: any) => {
const dialect = getMetadataDialect(node?.dataRef as SavedConnection);
if (!isPostgresSchemaDialect(dialect)) {
message.warning(t('sidebar.message.schema_create_unsupported'));
return;
}
setCreateSchemaTarget(node);
createSchemaForm.resetFields();
setIsCreateSchemaModalOpen(true);
};
const handleCreateSchema = async () => {
try {
const values = await createSchemaForm.validateFields();
const node = createSchemaTarget;
const conn = node?.dataRef;
const dbName = String(conn?.dbName || node?.title || '').trim();
if (!conn || !dbName) {
message.error(t('sidebar.message.schema_target_missing'));
return;
}
const res = await CreateSchema(buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, values.name);
if (res.success) {
message.success(t('sidebar.message.schema_created'));
setIsCreateSchemaModalOpen(false);
setCreateSchemaTarget(null);
createSchemaForm.resetFields();
await loadTables(node);
} else {
message.error(t('sidebar.message.operation_create_failed', { error: res.message }));
}
} catch (e) {
// Validate failed
}
};
const openRenameSchemaModal = (node: any) => {
const dialect = getMetadataDialect(node?.dataRef as SavedConnection);
const schemaName = String(node?.dataRef?.schemaName || '').trim();
if (!isPostgresSchemaDialect(dialect) || !schemaName) {
message.warning('当前节点不支持通过此入口编辑模式');
return;
}
setRenameSchemaTarget(node);
renameSchemaForm.setFieldsValue({ newName: schemaName });
setIsRenameSchemaModalOpen(true);
};
const handleRenameSchema = async () => {
try {
const values = await renameSchemaForm.validateFields();
const node = renameSchemaTarget;
const conn = node?.dataRef;
const dbName = String(conn?.dbName || '').trim();
const oldSchemaName = String(conn?.schemaName || '').trim();
const newSchemaName = String(values?.newName || '').trim();
if (!conn || !dbName || !oldSchemaName || !newSchemaName) {
message.error('未找到目标模式,无法编辑');
return;
}
if (oldSchemaName === newSchemaName) {
message.warning('新旧模式名称相同,无需修改');
return;
}
const res = await (window as any).go.app.App.RenameSchema(
buildRpcConnectionConfig(conn.config, { database: dbName }) as any,
dbName,
oldSchemaName,
newSchemaName,
);
if (res.success) {
message.success('模式重命名成功');
const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${oldSchemaName || 'default'}`;
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
await loadTables(getDatabaseNodeRef(conn, dbName));
setIsRenameSchemaModalOpen(false);
setRenameSchemaTarget(null);
renameSchemaForm.resetFields();
} else {
message.error('编辑失败: ' + res.message);
}
} catch (e) {
// Validate failed
}
};
const handleDeleteSchema = (node: any) => {
const conn = node?.dataRef;
const dbName = String(conn?.dbName || '').trim();
const schemaName = String(conn?.schemaName || '').trim();
if (!conn || !dbName || !schemaName) {
message.error('未找到目标模式,无法删除');
return;
}
Modal.confirm({
title: '确认删除模式',
content: `确定删除模式 "${schemaName}" 吗?这将删除该模式及其中所有对象,操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const res = await (window as any).go.app.App.DropSchema(
buildRpcConnectionConfig(conn.config, { database: dbName }) as any,
dbName,
schemaName,
);
if (res.success) {
message.success('模式删除成功');
const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${schemaName || 'default'}`;
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
await loadTables(getDatabaseNodeRef(conn, dbName));
} else {
message.error('删除失败: ' + res.message);
}
}
});
};
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);
if (!descriptor) {
return [];
}
return [{
title: descriptor.title,
key: descriptor.key,
icon: <DashboardOutlined />,
type: 'jvm-diagnostic',
dataRef: {
...conn,
diagnosticTransport: descriptor.transport,
},
isLeaf: true,
}];
};
const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => {
const trimmedResourcePath = String(resourcePath || '').trim();
addTab({
id: `jvm-resource-${conn.id}-${providerMode}-${encodeURIComponent(trimmedResourcePath)}`,
title: trimmedResourcePath
? `${buildJVMTabTitle(conn.name, 'resource', providerMode)} · ${trimmedResourcePath}`
: buildJVMTabTitle(conn.name, 'resource', providerMode),
type: 'jvm-resource',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
resourcePath: trimmedResourcePath,
resourceKind,
});
};
const openJVMDiagnosticTab = (conn: SavedConnection) => {
const transport = conn.config.jvm?.diagnostic?.transport || 'agent-bridge';
addTab({
id: `jvm-diagnostic-${conn.id}`,
title: buildJVMTabTitle(conn.name, 'diagnostic', transport),
type: 'jvm-diagnostic',
connectionId: conn.id,
});
};
const getConnectionNodeRef = (connRef: any) => {
const latestConn = connections.find(c => c.id === connRef.id);
return { key: connRef.id, dataRef: latestConn || connRef };
};
const getDatabaseNodeRef = (connRef: any, dbName: string) => {
const latestConn = connections.find(c => c.id === connRef.id);
return {
key: `${connRef.id}-${dbName}`,
dataRef: { ...(latestConn || connRef), dbName }
};
};
const extractObjectName = (fullName: string) => {
return splitQualifiedName(String(fullName || '').trim()).objectName || String(fullName || '').trim();
};
const handleRenameDatabase = async () => {
if (!renameDbTarget) return;
try {
const values = await renameDbForm.validateFields();
const conn = renameDbTarget.dataRef;
const oldDbName = String(conn.dbName || '').trim();
const newDbName = String(values.newName || '').trim();
if (!oldDbName || !newDbName) {
message.error(t('sidebar.message.database_name_required'));
return;
}
if (oldDbName === newDbName) {
message.warning(t('sidebar.message.database_name_unchanged'));
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameDatabase(buildRpcConnectionConfig(config) as any, oldDbName, newDbName);
if (res.success) {
message.success(t('sidebar.message.database_renamed'));
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`)));
await loadDatabases(getConnectionNodeRef(conn));
setIsRenameDbModalOpen(false);
setRenameDbTarget(null);
renameDbForm.resetFields();
} else {
message.error(t('sidebar.message.operation_rename_failed', { error: res.message }));
}
} catch (e) {
// Validate failed
}
};
const handleDeleteDatabase = (node: any) => {
const conn = node.dataRef;
const dbName = String(conn.dbName || '').trim();
if (!dbName) return;
Modal.confirm({
title: t('sidebar.modal.confirm_delete_database.title'),
content: t('sidebar.modal.confirm_delete_database.content', { name: dbName }),
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName);
if (res.success) {
message.success(t('sidebar.message.database_deleted'));
closeTabsByDatabase(conn.id, dbName);
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
await loadDatabases(getConnectionNodeRef(conn));
} else {
message.error(t('sidebar.message.operation_drop_failed', { error: res.message }));
}
}
});
};
const handleRenameTable = async () => {
if (!renameTableTarget) return;
try {
const values = await renameTableForm.validateFields();
const conn = renameTableTarget.dataRef;
const oldTableName = String(conn.tableName || '').trim();
const newTableName = String(values.newName || '').trim();
if (!oldTableName || !newTableName) {
message.error("表名不能为空");
return;
}
if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) {
message.warning("新旧表名相同,无需修改");
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName);
if (res.success) {
message.success("表重命名成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
setIsRenameTableModalOpen(false);
setRenameTableTarget(null);
renameTableForm.resetFields();
} else {
message.error("重命名失败: " + res.message);
}
} catch (e) {
// Validate failed
}
};
const handleDeleteTable = (node: any) => {
const conn = node.dataRef;
const tableName = String(conn.tableName || '').trim();
if (!tableName) return;
Modal.confirm({
title: '确认删除表',
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName);
if (res.success) {
message.success("表删除成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error("删除失败: " + res.message);
}
}
});
};
const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => {
const conn = node.dataRef;
const tableName = String(conn.tableName || '').trim();
if (!tableName) return;
const { label, progressLabel } = getTableDataDangerActionMeta(action);
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!confirmed) return;
const config = buildRuntimeConfig(conn, conn.dbName);
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
const startTime = Date.now();
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
hide();
const duration = Date.now() - startTime;
const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : [];
const logSql = executedSQLs.length > 0
? executedSQLs.join(';\n') + ';'
: `/* ${label} ${tableName} */`;
if (res.success) {
message.success(`${progressLabel}成功`);
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'success',
duration,
message: res.message,
dbName: conn.dbName,
affectedRows: res.data?.count || 0,
});
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
return;
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: res.message,
dbName: conn.dbName,
});
if (res.message !== '已取消') {
message.error(`${progressLabel}失败: ${res.message}`);
}
} catch (e: any) {
const duration = Date.now() - startTime;
const errMsg = e?.message || String(e);
hide();
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: `/* ${label} ${tableName} - ERROR */`,
status: 'error',
duration,
message: errMsg,
dbName: conn.dbName,
});
message.error(`${progressLabel}失败: ${errMsg}`);
}
};
// --- 视图操作 ---
const openViewDefinition = (node: any) => {
const { viewName, dbName, id, schemaName } = node.dataRef;
const isMaterialized = node.type === 'materialized-view' || node.dataRef?.objectKind === 'materialized-view';
addTab({
id: `view-def-${id}-${dbName}-${viewName}`,
title: t(isMaterialized ? 'sidebar.tab.materialized_view_definition' : 'sidebar.tab.view_definition', { name: viewName }),
type: 'view-def',
connectionId: id,
dbName,
viewName,
viewKind: isMaterialized ? 'materialized' : 'view',
schemaName,
sidebarLocateKey: String(node.key || ''),
});
};
const openEditView = async (node: any) => {
const conn = node.dataRef;
const { viewName, dbName, id } = conn;
// 获取视图定义后打开查询编辑器
const dialect = getMetadataDialect(conn as SavedConnection);
const sqlTemplateHeader = `-- ${t('sidebar.sql_template.edit_view', { name: viewName })}`;
let template = `${sqlTemplateHeader}\n-- ${t('sidebar.sql_template.modify_then_execute')}\nCREATE OR REPLACE VIEW ${viewName} AS\nSELECT * FROM your_table;`;
try {
const config = buildRuntimeConfig(conn, dbName);
let queries: string[] = [];
switch (dialect) {
case 'mysql':
case 'starrocks':
queries = [`SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``];
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': {
const parts = splitQualifiedName(viewName);
const schema = parts.schemaName || 'public';
const name = parts.objectName || viewName;
queries = [`SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`];
break;
}
case 'sqlserver':
queries = buildSqlServerObjectDefinitionQueries('view', viewName, dbName, 'view_definition');
break;
case 'sqlite':
queries = [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`];
break;
case 'duckdb': {
const parts = splitQualifiedName(viewName);
const viewSchema = escapeSQLLiteral(parts.schemaName || 'main');
const viewObject = escapeSQLLiteral(parts.objectName || viewName);
queries = [`SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`];
break;
}
}
for (const query of queries) {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
const row = result.data[0] as Record<string, any>;
const def = dialect === 'sqlserver'
? extractSqlServerDefinitionRows(result.data, ['view_definition', 'definition'])
: row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
if (def) {
if (dialect === 'mysql') {
template = `${sqlTemplateHeader}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`;
} else if (dialect === 'sqlserver') {
template = /^\s*create\s+view\b/i.test(String(def))
? `${sqlTemplateHeader}\n${def}`
: `${sqlTemplateHeader}\nCREATE VIEW ${viewName} AS\n${def}`;
} else {
template = `${sqlTemplateHeader}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`;
}
break;
}
}
}
} catch { /* 降级使用模板 */ }
addTab({
id: `query-edit-view-${Date.now()}`,
title: t('sidebar.tab.edit_view', { name: viewName }),
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const openCreateView = (node: any) => {
const conn = node.dataRef;
const { dbName, id } = conn;
const dialect = getMetadataDialect(conn as SavedConnection);
let template: string;
switch (dialect) {
case 'mysql':
case 'starrocks':
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb':
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'sqlserver':
template = `CREATE VIEW dbo.view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'oracle': case 'dm':
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'sqlite':
case 'duckdb':
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
default:
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
}
addTab({
id: `query-create-view-${Date.now()}`,
title: t('sidebar.tab.create_view'),
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const openCreateStarRocksMaterializedView = (node: any) => {
const conn = node.dataRef;
const { dbName, id } = conn;
const schemaPrefix = String(conn.schemaName || dbName || '').trim();
const mvName = schemaPrefix ? `${schemaPrefix}.mv_name` : 'mv_name';
const template = buildStarRocksMaterializedViewPreviewSql({
name: mvName,
query: 'SELECT\n column1,\n COUNT(*) AS cnt\nFROM table_name\nGROUP BY column1',
distributionColumnNames: ['column1'],
refreshClause: 'REFRESH ASYNC',
properties: '"replication_num" = "1"',
});
addTab({
id: `query-create-starrocks-mv-${Date.now()}`,
title: t('sidebar.v2_database_menu.new_materialized_view'),
type: 'query',
connectionId: id,
dbName,
query: template,
});
};
const openCreateStarRocksExternalCatalog = (node: any) => {
const conn = node.dataRef;
const { dbName, id } = conn;
addTab({
id: `query-create-starrocks-catalog-${Date.now()}`,
title: t('sidebar.v2_database_menu.new_external_catalog'),
type: 'query',
connectionId: id,
dbName,
query: `CREATE EXTERNAL CATALOG catalog_name\nPROPERTIES (\n "type" = "hive",\n "hive.metastore.uris" = "thrift://127.0.0.1:9083"\n);`,
});
};
const openCreateStarRocksRollup = (node: any) => {
const conn = node.dataRef;
const { tableName, dbName, id } = conn;
const safeTable = String(tableName || 'table_name').trim();
const safeTableParts = [splitQualifiedName(safeTable).schemaName, splitQualifiedName(safeTable).objectName].filter(Boolean);
const quotedTable = safeTable.includes('`')
? safeTable
: (safeTableParts.length > 0 ? safeTableParts : [safeTable]).map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
addTab({
id: `query-create-starrocks-rollup-${Date.now()}`,
title: '新增 Rollup',
type: 'query',
connectionId: id,
dbName,
query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`,
});
};
const handleDropView = (node: any) => {
const conn = node.dataRef;
const viewName = String(conn.viewName || '').trim();
if (!viewName) return;
Modal.confirm({
title: '确认删除视图',
content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName);
if (res.success) {
message.success("视图删除成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error("删除失败: " + res.message);
}
}
});
};
const handleRenameView = async () => {
if (!renameViewTarget) return;
try {
const values = await renameViewForm.validateFields();
const conn = renameViewTarget.dataRef;
const oldViewName = String(conn.viewName || '').trim();
const newViewName = String(values.newName || '').trim();
if (!oldViewName || !newViewName) {
message.error("视图名称不能为空");
return;
}
if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) {
message.warning("新旧视图名相同,无需修改");
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName);
if (res.success) {
message.success("视图重命名成功");
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
setIsRenameViewModalOpen(false);
setRenameViewTarget(null);
renameViewForm.resetFields();
} else {
message.error("重命名失败: " + res.message);
}
} catch (e) {
// Validate failed
}
};
const openRenameSavedQueryModal = (query: SavedQuery) => {
setRenameSavedQueryTarget(query);
renameSavedQueryForm.setFieldsValue({ name: query.name || t('query_editor.save_modal.unnamed') });
setIsRenameSavedQueryModalOpen(true);
};
const resolveSavedQueryDisplayName = (name: string | null | undefined) => {
const rawName = String(name || '').trim();
return rawName || t('query_editor.save_modal.unnamed');
};
const {
loadDatabases,
loadJVMResources,
loadTables,
} = useSidebarTreeLoaders({
savedQueries,
externalSQLDirectories,
tableSortPreference,
tableAccessCount,
pinnedSidebarTables,
isV2Ui,
loadingNodesRef,
setConnectionStates,
setLoadedKeys,
replaceTreeNodeChildren,
buildRuntimeConfig,
buildJVMRuntimeConfig,
buildJVMDiagnosticTreeNodes,
resolveSavedQueryDisplayName,
decorateExternalSQLTreeNode,
});
const handleRenameSavedQuery = async () => {
if (!renameSavedQueryTarget) return;
try {
const values = await renameSavedQueryForm.validateFields();
const nextName = String(values.name || '').trim();
if (!nextName) {
message.error(t('query_editor.save_modal.name_required'));
return;
}
if (nextName === renameSavedQueryTarget.name) {
message.warning(t('sidebar.message.saved_query_name_unchanged'));
return;
}
const persisted = await saveQuery({
...renameSavedQueryTarget,
name: nextName,
});
const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] =>
list.map(node => {
if (node.type === 'saved-query' && node.dataRef?.id === renameSavedQueryTarget.id) {
return {
...node,
title: persisted.name,
dataRef: { ...(node.dataRef || renameSavedQueryTarget), ...persisted },
};
}
return node.children ? { ...node, children: updateSavedQueryNode(node.children) } : node;
});
const nextTreeData = updateSavedQueryNode(treeDataRef.current);
treeDataRef.current = nextTreeData;
setTreeData(nextTreeData);
tabs
.filter(tab => tab.type === 'query' && (tab.savedQueryId === renameSavedQueryTarget.id || tab.id === renameSavedQueryTarget.id))
.forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name }));
message.success(t('sidebar.message.saved_query_renamed'));
setIsRenameSavedQueryModalOpen(false);
setRenameSavedQueryTarget(null);
renameSavedQueryForm.resetFields();
} catch (e) {
if (e instanceof Error) {
message.error('重命名查询失败: ' + e.message);
}
}
};
const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => {
return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId);
}, [connectionIdSet]);
const handleRebindSavedQuery = useCallback(async (query: SavedQuery, target: SavedConnection) => {
if (!query?.id || !target?.id) return;
try {
const backendApp = (window as any).go?.app?.App;
let persisted: SavedQuery;
if (typeof backendApp?.RebindSavedQuery === 'function') {
persisted = await backendApp.RebindSavedQuery(query.id, target.id);
await saveQuery(persisted);
} else {
persisted = await saveQuery({
...query,
connectionId: target.id,
originalConnectionId: query.originalConnectionId || query.connectionId,
bindingStatus: 'active',
});
}
message.success(`查询已绑定到 ${target.name || target.id}`);
tabs
.filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id))
.forEach(tab => updateQueryTabDraft(tab.id, {
title: persisted.name,
connectionId: persisted.connectionId,
dbName: persisted.dbName,
}));
} catch (error) {
message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error)));
}
}, [saveQuery, tabs, updateQueryTabDraft]);
// --- 函数/存储过程操作 ---
const openRoutineDefinition = (node: any) => {
const { routineName, routineType, dbName, id } = node.dataRef;
const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function');
addTab({
id: `routine-def-${id}-${dbName}-${routineName}`,
title: t('sidebar.tab.routine_definition', { type: typeLabel, name: routineName }),
type: 'routine-def',
connectionId: id,
dbName,
routineName,
routineType
});
};
const openEventDefinition = (node: any) => {
const { eventName, dbName, id } = node.dataRef;
addTab({
id: `event-def-${id}-${dbName}-${eventName}`,
title: t('sidebar.tab.event', { name: eventName }),
type: 'event-def',
connectionId: id,
dbName,
eventName,
});
};
const openEditRoutine = async (node: any) => {
const conn = node.dataRef;
const { routineName, routineType, dbName, id } = conn;
const dialect = getMetadataDialect(conn as SavedConnection);
const tabTypeKey = routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function';
const tabTypeLabel = t(tabTypeKey);
const sqlTemplateHeader = `-- ${t('sidebar.sql_template.edit_routine', { type: tabTypeLabel, name: routineName })}`;
let template = sqlTemplateHeader;
try {
const config = buildRuntimeConfig(conn, dbName);
let query = '';
const parsedRoutine = splitQualifiedName(routineName);
const name = parsedRoutine.objectName || routineName;
const schema = parsedRoutine.schemaName;
switch (dialect) {
case 'mysql':
case 'starrocks':
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': {
const schemaRef = schema || 'public';
query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`;
break;
}
case 'sqlserver':
query = '';
break;
case 'oracle': case 'dm': {
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : '';
if (owner) {
query = `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
} else {
query = `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`;
}
break;
}
case 'duckdb': {
const schemaRef = schema || 'main';
query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`;
break;
}
}
const queries = dialect === 'sqlserver'
? buildSqlServerObjectDefinitionQueries('routine', routineName, dbName, 'routine_definition')
: [query].filter(Boolean);
for (const queryText of queries) {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, queryText);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
if (dialect === 'oracle' || dialect === 'dm') {
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
if (lines) {
template = `${sqlTemplateHeader}\nCREATE OR REPLACE ${lines}`;
break;
}
} else if (dialect === 'duckdb') {
const row = result.data[0] as Record<string, any>;
const ddl = buildDuckDBMacroDDL(
String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(),
String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(),
getCaseInsensitiveRawValue(row, ['parameters']),
getCaseInsensitiveRawValue(row, ['macro_definition'])
);
if (ddl) {
template = `${sqlTemplateHeader}\n${ddl}`;
break;
}
} else {
const row = result.data[0] as Record<string, any>;
const def = dialect === 'sqlserver'
? extractSqlServerDefinitionRows(result.data, ['routine_definition', 'definition'])
: row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
if (def) {
template = `${sqlTemplateHeader}\n${def}`;
break;
}
}
}
}
} catch { /* 降级使用模板 */ }
addTab({
id: `query-edit-routine-${Date.now()}`,
title: t('sidebar.tab.edit_routine', { type: tabTypeLabel, name: routineName }),
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const openCreateRoutine = (node: any, type: 'FUNCTION' | 'PROCEDURE') => {
const conn = node.dataRef;
const { dbName, id } = conn;
const dialect = getMetadataDialect(conn as SavedConnection);
const isProc = type === 'PROCEDURE';
let template: string;
switch (dialect) {
case 'mysql':
case 'starrocks':
template = isProc
? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;`
: `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb':
template = isProc
? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;`
: `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`;
break;
case 'sqlserver':
template = isProc
? `CREATE PROCEDURE dbo.proc_name\n @param1 INT\nAS\nBEGIN\n SELECT * FROM table_name WHERE id = @param1;\nEND;`
: `CREATE FUNCTION dbo.func_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1 * 2;\nEND;`;
break;
case 'oracle': case 'dm':
template = isProc
? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;`
: `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`;
break;
case 'duckdb':
template = isProc
? `-- ${t('sidebar.sql_template.duckdb_procedure_unsupported')}\n-- ${t('sidebar.sql_template.duckdb_macro_hint')}\nCREATE MACRO func_name(param1) AS (param1 * 2);`
: `CREATE MACRO func_name(param1) AS (param1 * 2);`;
break;
default:
template = isProc
? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;`
: `CREATE FUNCTION func_name()\nRETURNS INTEGER\nBEGIN\n RETURN 0;\nEND;`;
}
addTab({
id: `query-create-routine-${Date.now()}`,
title: isProc ? t('sidebar.tab.create_procedure') : t('sidebar.tab.create_function'),
type: 'query',
connectionId: id,
dbName,
query: template
});
};
const handleDropRoutine = (node: any) => {
const conn = node.dataRef;
const routineName = String(conn.routineName || '').trim();
const routineType = String(conn.routineType || 'FUNCTION').trim();
if (!routineName) return;
const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function');
Modal.confirm({
title: t('sidebar.modal.confirm_delete_routine.title', { type: typeLabel }),
content: t('sidebar.modal.confirm_delete_routine.content', { type: typeLabel, name: routineName }),
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropFunction(buildRpcConnectionConfig(config) as any, conn.dbName, routineName, routineType);
if (res.success) {
message.success(t('sidebar.message.routine_deleted', { type: typeLabel }));
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error(t('sidebar.message.delete_failed', { error: res.message }));
}
}
});
};
const resolveMessagePublishTarget = (node: any): SidebarMessagePublishTarget | null => {
const connectionId = String(node?.dataRef?.id || '').trim();
const liveConnection = connections.find((item) => item.id === connectionId);
const sourceConnection = (liveConnection || node?.dataRef) as SavedConnection | undefined;
if (!sourceConnection?.config) return null;
const capabilities = getDataSourceCapabilities(sourceConnection.config);
if (!capabilities.supportsMessagePublish) return null;
return {
connection: sourceConnection,
executionDbName: String(node?.dataRef?.dbName || ''),
destination: String(node?.dataRef?.tableName || node?.title || '').trim(),
};
};
const openMessagePublishModal = (node: any) => {
const target = resolveMessagePublishTarget(node);
if (!target) {
message.warning('当前对象不支持测试发送消息');
return;
}
setMessagePublishTarget(target);
};
const handleMessagePublishSuccess = (result: { destination: string; affectedRows: number }) => {
const destination = String(result.destination || '').trim();
const suffix = result.affectedRows > 0 ? `(已提交 ${result.affectedRows} 条)` : '';
message.success(`测试消息已发送到 ${destination || '目标'}${suffix}`);
setMessagePublishTarget(null);
};
const handleV2TableContextMenuAction = (node: any, action: V2TableContextMenuActionKey) => {
switch (action) {
case 'pin-table':
case 'unpin-table': {
toggleSidebarTablePinned(node, action === 'pin-table');
return;
}
case 'open-data':
case 'open-new-tab':
onDoubleClick(null, node);
return;
case 'design-table':
openDesign(node, 'columns', false);
return;
case 'new-query': {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate
});
return;
}
case 'publish-message':
openMessagePublishModal(node);
return;
case 'view-ddl':
openTableDdlInDesigner(node);
return;
case 'view-er':
openTableInERView(node);
return;
case 'copy-table-name':
void handleCopyTableName(node);
return;
case 'copy-structure':
void handleCopyStructure(node);
return;
case 'copy-insert':
void handleCopyTableAsInsert(node);
return;
case 'rename-table':
setRenameTableTarget(node);
renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) });
setIsRenameTableModalOpen(true);
return;
case 'new-rollup':
openCreateStarRocksRollup(node);
return;
case 'backup-table':
void handleExport(node, { format: 'sql' });
return;
case 'refresh-stats':
refreshV2TableContextMenuStats(node);
return;
case 'export-data':
void openExportDialog(node);
return;
case 'ai-explain':
void injectTablePromptToAI(node, 'explain');
return;
case 'ai-generate-query':
void injectTablePromptToAI(node, 'query');
return;
case 'truncate-table':
void handleTableDataDangerAction(node, 'truncate');
return;
case 'drop-table':
handleDeleteTable(node);
return;
default:
return;
}
};
const toggleSidebarTablePinned = (node: any, pinned?: boolean) => {
const conn = node?.dataRef || {};
const tableName = String(conn.tableName || node?.title || '').trim();
const dbName = String(conn.dbName || '').trim();
if (!conn.id || !dbName || !tableName) return;
const currentlyPinned = isSidebarTablePinned(
pinnedSidebarTables,
String(conn.id || ''),
dbName,
tableName,
String(conn.schemaName || ''),
);
const shouldPin = pinned ?? !currentlyPinned;
setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin);
void loadTables(getDatabaseNodeRef(conn, dbName));
message.success(shouldPin ? t('sidebar.message.table_pinned') : t('sidebar.message.table_unpinned'));
};
const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => {
const groupData = node.dataRef;
setTableSortPreference(groupData.id, groupData.dbName, sortBy);
const dbNode = {
key: `${groupData.id}-${groupData.dbName}`,
dataRef: groupData
};
loadTables(dbNode);
};
const handleV2TableGroupContextMenuAction = (node: any, action: V2TableGroupContextMenuActionKey) => {
switch (action) {
case 'new-table':
openNewTableDesign(node);
return;
case 'sort-by-name':
handleTableGroupSortAction(node, 'name');
return;
case 'sort-by-frequency':
handleTableGroupSortAction(node, 'frequency');
return;
default:
return;
}
};
const closeDatabaseNode = (node: any) => {
const dbConnId = String(node.dataRef?.id || '');
const dbName = String(node.dataRef?.dbName || node.title || '').trim();
loadingNodesRef.current.delete(`tables-${dbConnId}-${dbName}`);
setConnectionStates(prev => {
const next = { ...prev };
delete next[node.key];
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
replaceTreeNodeChildren(node.key, undefined);
if (dbConnId && dbName) {
closeTabsByDatabase(dbConnId, dbName);
}
message.success(t('sidebar.message.database_closed'));
};
const openDatabaseQuery = (node: any) => {
addTab({
id: `query-${Date.now()}`,
title: t('sidebar.tab.new_query_database', { database: node.title }),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.title,
query: ''
});
};
const handleV2DatabaseContextMenuAction = (node: any, action: V2DatabaseContextMenuActionKey) => {
switch (action) {
case 'new-table':
openNewTableDesign(node);
return;
case 'new-schema':
openCreateSchemaModal(node);
return;
case 'new-materialized-view':
openCreateStarRocksMaterializedView(node);
return;
case 'new-external-catalog':
openCreateStarRocksExternalCatalog(node);
return;
case 'rename-db':
setRenameDbTarget(node);
renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' });
setIsRenameDbModalOpen(true);
return;
case 'refresh':
loadTables(node);
return;
case 'export-db-schema':
void handleExportDatabaseSQL(node, false);
return;
case 'backup-db-sql':
void handleExportDatabaseSQL(node, true);
return;
case 'disconnect-db':
closeDatabaseNode(node);
return;
case 'new-query':
openDatabaseQuery(node);
return;
case 'run-sql':
handleRunSQLFile(node);
return;
case 'drop-db':
handleDeleteDatabase(node);
return;
default:
return;
}
};
const refreshConnectionNode = (node: any) => {
const connKey = String(node?.key || node?.dataRef?.id || '');
if (!connKey) return;
setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
loadingNodesRef.current.delete(loadingKey);
}
});
loadDatabases(node);
};
const releaseConnectionResources = async (conn: SavedConnection | undefined) => {
if (!conn?.config) return;
const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any);
if (res && res.success === false) {
throw new Error(res.message || '释放连接失败');
}
};
const disconnectConnectionNode = async (node: any) => {
const connKey = String(node?.key || node?.dataRef?.id || '');
if (!connKey) return;
const conn = (connections.find((item) => item.id === connKey) || node?.dataRef) as SavedConnection | undefined;
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
loadingNodesRef.current.delete(loadingKey);
}
});
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === connKey || k.startsWith(`${connKey}-`)) {
delete next[k];
}
});
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
replaceTreeNodeChildren(connKey, undefined);
closeTabsByConnection(connKey);
try {
await releaseConnectionResources(conn);
} catch (error: any) {
message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败');
}
message.success(t('connection.sidebar.disconnect.success'));
};
const deleteConnectionNode = (node: any) => {
Modal.confirm({
title: t('connection.sidebar.delete.confirmTitle'),
content: t('connection.sidebar.delete.confirmContent', { name: node.title }),
onOk: async () => {
const connId = String(node.key);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DeleteConnection !== 'function') {
message.error(t('connection.sidebar.delete.backendUnavailable'));
throw new Error('DeleteConnection unavailable');
}
try {
await backendApp.DeleteConnection(connId);
closeTabsByConnection(connId);
removeConnection(connId);
message.success(t('connection.sidebar.delete.success'));
} catch (error: any) {
message.error(error?.message || t('connection.sidebar.delete.failureFallback'));
throw error;
}
}
});
};
const createConnectionTreeNode = (conn: SavedConnection): TreeNode => ({
title: conn.name,
key: conn.id,
icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 22),
type: 'connection',
dataRef: conn,
isLeaf: false,
});
const getConnectionNodeForAction = (conn: SavedConnection): TreeNode => {
return findTreeNodeByKeyRef.current(treeDataRef.current, conn.id) || createConnectionTreeNode(conn);
};
const handleV2ConnectionContextMenuAction = (node: any, action: V2ConnectionContextMenuActionKey) => {
const connId = String(node?.key || node?.dataRef?.id || '');
if (!connId) return;
switch (action) {
case 'new-db':
setTargetConnection(node);
setIsCreateDbModalOpen(true);
return;
case 'refresh':
refreshConnectionNode(node);
return;
case 'new-query':
addTab({
id: `query-${Date.now()}`,
title: buildConnectionRootQueryTabTitle(),
type: 'query',
connectionId: connId,
dbName: undefined,
query: ''
});
return;
case 'open-sql-file':
handleRunSQLFile(node);
return;
case 'new-command':
addTab({
id: `redis-cmd-${connId}-${Date.now()}`,
title: buildConnectionRootRedisCommandTabTitle(),
type: 'redis-command',
connectionId: connId,
redisDB: 0
});
return;
case 'open-monitor':
addTab({
id: `redis-monitor-${connId}-${Date.now()}`,
title: buildConnectionRootRedisMonitorTabTitle(),
type: 'redis-monitor',
connectionId: connId,
redisDB: 0
});
return;
case 'edit':
if (onEditConnection) onEditConnection(node.dataRef);
return;
case 'copy-connection':
void handleDuplicateConnection(node.dataRef as SavedConnection);
return;
case 'disconnect':
void disconnectConnectionNode(node);
return;
case 'delete':
deleteConnectionNode(node);
return;
case 'move-to-ungrouped':
moveConnectionToTag(connId, null);
return;
default:
if (action.startsWith('move-to-tag:')) {
moveConnectionToTag(connId, action.slice('move-to-tag:'.length));
}
}
};
const handleV2ConnectionGroupContextMenuAction = (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => {
const tag = connectionTags.find((item) => item.id === group.id);
if (!tag) return;
if (action === 'edit-group') {
createTagForm.setFieldsValue({ name: tag.name, connectionIds: tag.connectionIds });
setRenameViewTarget({
title: tag.name,
key: `tag-${tag.id}`,
type: 'tag',
dataRef: tag,
});
setIsCreateTagModalOpen(true);
return;
}
if (action === 'delete-group') {
Modal.confirm({
title: t('connection.sidebar.group.deleteConfirmTitle'),
content: t('connection.sidebar.group.deleteConfirmContent', { name: tag.name }),
onOk: () => {
removeConnectionTag(tag.id);
},
});
}
};
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setSearchValue(value);
};
const toggleSearchScope = (scope: SearchScope) => {
setSearchScopes((prev) => {
if (scope === 'smart') {
return ['smart'];
}
const withoutSmart = prev.filter((item) => item !== 'smart');
if (withoutSmart.includes(scope)) {
const next = withoutSmart.filter((item) => item !== scope);
return next.length > 0 ? next : ['smart'];
}
return [...withoutSmart, scope];
});
};
const setSearchScopeChecked = (scope: SearchScope, checked: boolean) => {
if (scope === 'smart') {
if (checked) {
setSearchScopes(['smart']);
} else if (searchScopes.length === 1 && searchScopes[0] === 'smart') {
setSearchScopes(['smart']);
} else {
setSearchScopes((prev) => {
const next = prev.filter((item) => item !== 'smart');
return next.length > 0 ? next : ['smart'];
});
}
return;
}
if (checked) {
setSearchScopes((prev) => {
const withoutSmart = prev.filter((item) => item !== 'smart');
if (withoutSmart.includes(scope)) {
return withoutSmart;
}
return [...withoutSmart, scope];
});
} else {
setSearchScopes((prev) => {
const next = prev.filter((item) => item !== scope && item !== 'smart');
return next.length > 0 ? next : ['smart'];
});
}
};
const currentLanguage = getCurrentLanguage();
const searchScopeSummary = useMemo(() => {
if (searchScopes.includes('smart')) {
return t('sidebar.command_search.scope.summary_smart');
}
return searchScopes.map((scope) => t(SEARCH_SCOPE_LABEL_KEY_MAP[scope])).join(' + ');
}, [searchScopes, currentLanguage]);
const searchScopePopoverContent = useMemo(() => {
const smartSelected = searchScopes.includes('smart');
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
const mutedTextColor = overlayTheme.mutedText;
const titleColor = overlayTheme.titleText;
const panelBg = overlayTheme.shellBg;
const smartBg = smartSelected
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
const smartBorder = smartSelected
? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)')
: borderColor;
const getOptionCardStyle = (checked: boolean) => ({
display: 'flex',
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
gap: 12,
padding: '10px 12px',
borderRadius: 12,
border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`,
background: checked
? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'),
transition: 'all 120ms ease',
});
return (
<div style={{ minWidth: 280, display: 'flex', flexDirection: 'column', background: panelBg, padding: 14, gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: mutedTextColor, textTransform: 'uppercase' }}>{t('sidebar.command_search.scope.title')}</div>
<div style={{ marginTop: 4, fontSize: 13, lineHeight: 1.5, color: mutedTextColor }}>{t('sidebar.command_search.scope.description')}</div>
</div>
<div style={{ width: 32, height: 32, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<FilterOutlined />
</div>
</div>
<label style={{ display: 'block', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 14, border: `1px solid ${smartBorder}`, background: smartBg, boxShadow: smartSelected ? (darkMode ? '0 10px 24px rgba(0,0,0,0.24)' : '0 10px 24px rgba(245,176,65,0.14)') : 'none' }}>
<Checkbox
checked={smartSelected}
onChange={(e) => setSearchScopeChecked('smart', e.target.checked)}
/>
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.3)', color: darkMode ? '#ffd666' : '#ad6800', flexShrink: 0 }}>
{SEARCH_SCOPE_ICON_MAP.smart}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: titleColor }}>{t('sidebar.command_search.scope.smart')}</span>
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, color: darkMode ? '#ffe58f' : '#ad6800', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.35)' }}>{t('sidebar.command_search.scope.recommended')}</span>
</div>
<div style={{ marginTop: 3, fontSize: 12, lineHeight: 1.5, color: mutedTextColor }}>{t('sidebar.command_search.scope.smart_help')}</div>
</div>
</div>
</label>
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}>{t('sidebar.command_search.scope.manual_title')}</div>
<div style={{ fontSize: 12, color: mutedTextColor }}>{t('sidebar.command_search.scope.multi_select')}</div>
</div>
<div style={{ display: 'grid', gap: 8 }}>
{scopedOptions.map((option) => {
const checked = searchScopes.includes(option.value);
return (
<label key={option.value} style={{ display: 'block', cursor: 'pointer' }}>
<div style={getOptionCardStyle(checked)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<Checkbox
checked={checked}
onChange={(e) => setSearchScopeChecked(option.value, e.target.checked)}
/>
<div style={{ width: 28, height: 28, borderRadius: 9, display: 'grid', placeItems: 'center', background: checked ? (darkMode ? 'rgba(118,169,250,0.2)' : 'rgba(24,144,255,0.12)') : (darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)'), color: checked ? (darkMode ? '#91caff' : '#1677ff') : mutedTextColor, flexShrink: 0 }}>
{SEARCH_SCOPE_ICON_MAP[option.value]}
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: titleColor, whiteSpace: 'nowrap' }}>{t(option.labelKey)}</span>
</div>
<div style={{ width: 18, display: 'flex', justifyContent: 'center', color: checked ? (darkMode ? '#91caff' : '#1677ff') : 'transparent', flexShrink: 0 }}>
<CheckOutlined />
</div>
</div>
</label>
);
})}
</div>
<div style={{ padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(17,24,39,0.04)', color: mutedTextColor, fontSize: 12, lineHeight: 1.6 }}>
{t('sidebar.command_search.scope.manual_help')}
</div>
</div>
);
}, [darkMode, overlayTheme, searchScopes, currentLanguage]);
const getConnectionHostSearchText = (node: TreeNode): string => {
if (node.type !== 'connection') return '';
const config = node.dataRef?.config || {};
return resolveConnectionHostTokens(config).join(' ');
};
const getConnectionNameSearchText = (node: TreeNode): string => {
if (node.type !== 'connection') return '';
const name = node.dataRef?.name ?? node.title;
return String(name || '').toLowerCase();
};
const matchByScopes = (node: TreeNode, keyword: string, scopes: SearchScope[]): boolean => {
const title = String(node.title || '').toLowerCase();
if (scopes.includes('database') && node.type === 'database' && title.includes(keyword)) {
return true;
}
if (scopes.includes('tag') && node.type === 'tag' && title.includes(keyword)) {
return true;
}
if (scopes.includes('host') && node.type === 'connection' && getConnectionHostSearchText(node).includes(keyword)) {
return true;
}
if (scopes.includes('object') && (isV2SidebarObjectNode(node) || node.type === 'object-group') && title.includes(keyword)) {
return true;
}
if (node.type === 'external-sql-root' || node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
const pathText = String(node?.dataRef?.path || '').toLowerCase();
return title.includes(keyword) || pathText.includes(keyword);
}
return false;
};
const loop = (data: TreeNode[], keyword: string): TreeNode[] => {
const isSmartMode = searchScopes.includes('smart');
const result: TreeNode[] = [];
data.forEach((item) => {
const titleMatch = String(item.title || '').toLowerCase().includes(keyword);
const smartMatch = item.type === 'connection'
? getConnectionNameSearchText(item).includes(keyword) || getConnectionHostSearchText(item).includes(keyword)
: titleMatch;
const scopedMatch = matchByScopes(item, keyword, searchScopes);
const selfMatch = isSmartMode ? smartMatch : scopedMatch;
const filteredChildren = item.children ? loop(item.children, keyword) : [];
if (selfMatch) {
const shouldKeepFullSubtree = isSmartMode
|| item.type === 'connection'
|| item.type === 'database'
|| item.type === 'tag'
|| item.type === 'external-sql-root'
|| item.type === 'external-sql-directory'
|| item.type === 'external-sql-folder';
if (item.children && shouldKeepFullSubtree) {
result.push(item);
} else if (item.children && filteredChildren.length > 0) {
result.push({ ...item, children: filteredChildren });
} else {
result.push(item);
}
return;
}
if (filteredChildren.length > 0) {
result.push({ ...item, children: filteredChildren });
}
});
return result;
};
const displayTreeData = useMemo(() => {
const keyword = deferredSearchValue.trim().toLowerCase();
if (!keyword) return treeData;
return loop(treeData, keyword);
}, [deferredSearchValue, searchScopes, treeData]);
const commandSearchTreeItems = useMemo(() => {
const result: V2CommandSearchItem[] = [];
const visit = (nodes: TreeNode[]) => {
nodes.forEach((node) => {
const dataRef = node.dataRef || {};
if (node.type === 'connection') {
const conn = dataRef as SavedConnection;
result.push({
key: `node-${node.key}`,
kind: 'node',
title: String(node.title || conn.name || t('connection.unnamed')),
meta: resolveConnectionHostSummary(conn.config) || conn.config?.type || t('connection.sidebar.menu.section'),
icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 16),
node,
});
} else if (node.type === 'database') {
const conn = connections.find((item) => item.id === dataRef.id);
result.push({
key: `node-${node.key}`,
kind: 'node',
title: String(node.title || dataRef.dbName || t('database.unnamed')),
meta: conn?.name || dataRef.id || t('database.label'),
icon: <DatabaseOutlined />,
node,
});
} else if (
node.type === 'table'
|| node.type === 'view'
|| node.type === 'materialized-view'
|| node.type === 'db-trigger'
|| node.type === 'db-event'
|| node.type === 'routine'
) {
const conn = connections.find((item) => item.id === dataRef.id);
const objectName = String(dataRef.tableName || dataRef.viewName || dataRef.triggerName || dataRef.eventName || dataRef.routineName || node.title || '').trim();
const displayName = String(node.title || extractObjectName(objectName) || objectName).trim();
result.push({
key: `node-${node.key}`,
kind: 'node',
title: displayName,
meta: [conn?.name || dataRef.id, dataRef.dbName].filter(Boolean).join(' · '),
icon: node.type === 'table'
? <TableOutlined />
: (node.type === 'db-event' ? <ClockCircleOutlined /> : (node.type === 'routine' ? <CodeOutlined /> : <EyeOutlined />)),
node,
});
}
if (node.children) visit(node.children);
});
};
visit(treeData);
return result;
}, [connections, treeData]);
const commandSearchRecentItems = useMemo<V2CommandSearchItem[]>(() => {
return sqlLogs.slice(0, 5).map((log) => ({
key: `recent-${log.id}`,
kind: 'recent',
title: log.sql.replace(/\s+/g, ' ').trim() || 'SQL 记录',
meta: `${new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · ${log.duration}ms${log.dbName ? ` · ${log.dbName}` : ''}`,
icon: <ClockCircleOutlined />,
sql: log.sql,
dbName: log.dbName,
}));
}, [sqlLogs]);
const commandSearchActionItems = useMemo<V2CommandSearchItem[]>(() => [
{
key: 'action-new-query',
kind: 'action',
title: t('query.new'),
meta: '打开一个新的 SQL 编辑页',
shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform),
icon: <PlusOutlined />,
onRun: () => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab')),
},
{
key: 'action-new-connection',
kind: 'action',
title: '新建数据源',
meta: '创建数据库、运行时或其他数据源连接',
shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform),
icon: <ThunderboltOutlined />,
onRun: () => onCreateConnection?.(),
},
{
key: 'action-open-ai',
kind: 'action',
title: '打开 AI 数据洞察',
meta: '让 AI 分析当前数据库上下文',
shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform),
icon: <RobotOutlined />,
onRun: () => onToggleAI?.(),
},
{
key: 'action-open-sql-log',
kind: 'action',
title: '查看 SQL 执行日志',
meta: '打开最近执行记录面板',
shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform),
icon: <BarsOutlined />,
onRun: () => onToggleLogPanel?.(),
},
], [activeShortcutPlatform, onCreateConnection, onToggleAI, onToggleLogPanel, shortcutOptions]);
const v2CommandSearchQuery = useMemo(
() => parseV2CommandSearchQuery(deferredV2CommandSearchValue),
[deferredV2CommandSearchValue],
);
const normalizedV2CommandSearchValue = v2CommandSearchQuery.normalizedKeyword;
const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';
const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';
const filteredCommandSearchTreeItems = useMemo(() => {
return filterV2CommandSearchTreeItems(commandSearchTreeItems, v2CommandSearchQuery);
}, [commandSearchTreeItems, v2CommandSearchQuery]);
const filteredCommandSearchActionItems = useMemo(() => {
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
if (!normalizedV2CommandSearchValue) return commandSearchActionItems;
return commandSearchActionItems.filter((item) => {
const haystack = `${item.title} ${item.meta}`.toLowerCase();
return haystack.includes(normalizedV2CommandSearchValue);
});
}, [commandSearchActionItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
const filteredCommandSearchRecentItems = useMemo(() => {
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
if (!normalizedV2CommandSearchValue) return commandSearchRecentItems;
return commandSearchRecentItems.filter((item) => {
const haystack = `${item.title} ${item.meta}`.toLowerCase();
return haystack.includes(normalizedV2CommandSearchValue);
});
}, [commandSearchRecentItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
const commandSearchAiItem = useMemo<V2CommandSearchItem[]>(() => {
if (!v2CommandSearchAiMode || !v2CommandSearchQuery.aiPrompt) return [];
return [{
key: 'action-ask-ai',
kind: 'action',
title: '让 AI 回答',
meta: v2CommandSearchQuery.aiPrompt,
shortcut: '↵',
icon: <RobotOutlined />,
onRun: () => {
const wasClosed = !useStore.getState().aiPanelVisible;
if (wasClosed) setAIPanelVisible(true);
window.setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', {
detail: { prompt: v2CommandSearchQuery.aiPrompt },
}));
}, wasClosed ? 350 : 0);
},
}];
}, [setAIPanelVisible, v2CommandSearchAiMode, v2CommandSearchQuery.aiPrompt]);
const commandSearchFlatItems = useMemo(
() => [
...commandSearchAiItem,
...filteredCommandSearchTreeItems,
...filteredCommandSearchActionItems,
...filteredCommandSearchRecentItems,
],
[commandSearchAiItem, filteredCommandSearchActionItems, filteredCommandSearchRecentItems, filteredCommandSearchTreeItems],
);
useEffect(() => {
setV2CommandActiveIndex(0);
}, [v2CommandSearchValue, commandSearchFlatItems.length]);
const flattenConnectionNodes = useCallback((nodes: TreeNode[]): TreeNode[] => {
const result: TreeNode[] = [];
nodes.forEach((node) => {
if (node.type === 'connection') {
result.push(node);
}
if (node.children) {
result.push(...flattenConnectionNodes(node.children));
}
});
return result;
}, []);
const activeConnectionId = resolveV2ActiveConnectionId({
activeContextConnectionId: activeContext?.connectionId,
activeTabConnectionId: activeTab?.connectionId,
selectedKeys,
connectionIds,
fallbackConnectionId: selectedNodesRef.current
.map((node) => resolveSidebarNodeConnectionId(node, connectionIds))
.find(Boolean),
});
const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || null;
const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected');
const activeDatabaseDisplayName = useMemo(() => {
if (activeContext && typeof activeContext === 'object' && 'dbName' in activeContext) {
return String(activeContext.dbName || '').trim();
}
return String(activeTab?.dbName || '').trim();
}, [activeContext, activeTab?.dbName]);
const activeConnectionTreeData = useMemo(() => {
const externalSQLNodes = displayTreeData.filter((node) => node.type === 'external-sql-root');
if (!activeConnection) return displayTreeData;
const activeConnectionNode = displayTreeData.find((node) => node.type === 'connection' && node.key === activeConnection.id);
if (activeConnectionNode) {
return [
...(activeConnectionNode.children && activeConnectionNode.children.length > 0 ? activeConnectionNode.children : []),
...externalSQLNodes,
];
}
const filterTree = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((node) => {
if (node.type === 'tag') {
return filterTree(node.children || []);
}
if (node.type === 'connection') {
if (node.key !== activeConnection.id) return [];
return node.children && node.children.length > 0 ? filterTree(node.children) : [];
}
return [{ ...node, children: node.children ? filterTree(node.children) : undefined }];
});
const filtered = filterTree(displayTreeData);
return [...filtered, ...externalSQLNodes];
}, [activeConnection, displayTreeData]);
const v2VisibleTreeData = useMemo(() => {
if (v2ExplorerFilter === 'all') {
return displayTreeData;
}
return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter);
}, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]);
const v2TreeHorizontalScrollWidth = useMemo(
() => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth),
[treeViewportWidth, v2VisibleTreeData],
);
const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth
? Math.max(1, treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE)
: treeHeight;
const v2TreeMetrics = useMemo(() => {
const databaseTableCounts = new Map<React.Key, number>();
const objectGroupCounts = new Map<React.Key, number>();
let activeObjectCount = 0;
const visitAndCount = (node: TreeNode): number => {
const childCount = (node.children || []).reduce((total, child) => total + visitAndCount(child), 0);
const totalCount = (isV2SidebarObjectNode(node) ? 1 : 0) + childCount;
if (node.type === 'database') {
const tableCount = (node.children || []).reduce((total, child) => {
if (child.type === 'object-group' && child?.dataRef?.groupKey === 'tables') {
return total + (Array.isArray(child.children) ? child.children.filter((item) => item.type === 'table').length : 0);
}
if (child?.dataRef?.groupKey === 'schema' && Array.isArray(child.children)) {
return total + child.children.reduce((schemaTotal, schemaChild) => {
if (schemaChild.type === 'object-group' && schemaChild?.dataRef?.groupKey === 'tables') {
return schemaTotal + (Array.isArray(schemaChild.children) ? schemaChild.children.filter((item) => item.type === 'table').length : 0);
}
return schemaTotal;
}, 0);
}
return total;
}, 0);
databaseTableCounts.set(node.key, tableCount);
} else if (node.type === 'object-group') {
objectGroupCounts.set(node.key, childCount);
}
return totalCount;
};
activeObjectCount = v2VisibleTreeData.reduce((total, node) => total + visitAndCount(node), 0);
return {
activeObjectCount,
databaseTableCounts,
objectGroupCounts,
};
}, [v2VisibleTreeData]);
const activeConnectionObjectCount = v2TreeMetrics.activeObjectCount;
const legacyToolbarButtonColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
const legacyToolbarStyle: React.CSSProperties = {
padding: '6px 16px',
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
gap: 8,
alignItems: 'center',
justifyItems: 'center',
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`,
borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`,
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)',
};
const legacyToolbarItemStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 0,
};
const legacyToolbarDisabledWrapStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
const {
contextMenu,
setContextMenu,
contextMenuPortalRef,
buildRailConnectionStatus,
openV2ConnectionContextMenu,
getV2TreeMetaText,
renderV2SidebarContextMenuContent,
fetchV2TableContextMenuStats,
refreshV2TableContextMenuStats,
} = useSidebarV2ContextMenu({
connections,
connectionStates,
connectionTags,
activeShortcutPlatform,
flattenConnectionNodes,
v2TreeMetrics,
tableSortPreference,
pinnedSidebarTables,
getConnectionNodeForAction,
buildRuntimeConfig,
extractObjectName,
isPostgresSchemaDialect,
loadTables,
getDatabaseNodeRef,
handleExportSchemaSQL,
handleDeleteSchema,
openRenameSchemaModal,
resolveMessagePublishTarget,
addSqlLog,
handleV2TableContextMenuAction,
handleV2TableGroupContextMenuAction,
handleV2DatabaseContextMenuAction,
handleV2ConnectionContextMenuAction,
handleV2ConnectionGroupContextMenuAction,
});
const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({
node,
hoverTitle,
statusBadge,
getV2TreeMetaText,
toggleSidebarTablePinned,
snapshotTreeSelectionBeforeDrag,
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
});
const selectConnectionFromRail = useCallback((conn: SavedConnection) => {
const key = conn.id;
const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, key);
setSelectedKeys([key]);
selectedNodesRef.current = connectionNode ? [connectionNode] : [];
setActiveContext({ connectionId: key, dbName: '' });
mergeExpandedTreeKeys([key]);
const targetNode = connectionNode || {
key,
dataRef: conn,
type: 'connection',
};
void loadDatabases(targetNode);
}, [setActiveContext]);
const runCommandSearchItem = useCallback((item?: V2CommandSearchItem) => {
if (!item) return;
closeV2CommandSearch();
if (item.kind === 'action') {
item.onRun();
return;
}
if (item.kind === 'recent') {
addTab({
id: `query-${Date.now()}`,
title: '最近查询',
type: 'query',
connectionId: item.connectionId || activeContext?.connectionId || activeTab?.connectionId || '',
dbName: item.dbName || activeContext?.dbName || activeTab?.dbName || '',
query: item.sql,
});
return;
}
const node = item.node;
const dataRef = node.dataRef || {};
if (node.type === 'connection') {
selectConnectionFromRail(dataRef as SavedConnection);
return;
}
if (node.type === 'database') {
setActiveContext({ connectionId: resolveSidebarNodeConnectionId(node, connectionIds) || dataRef.id, dbName: dataRef.dbName });
mergeExpandedTreeKeys([dataRef.id, node.key]);
setSelectedKeys([node.key]);
selectedNodesRef.current = [node];
scrollSidebarTreeToKey(node.key);
return;
}
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
void locateObjectInSidebar({
tabId: String(node.key || ''),
connectionId: dataRef.id,
dbName: dataRef.dbName,
tableName: dataRef.tableName || dataRef.viewName,
schemaName: dataRef.schemaName,
objectGroup: node.type === 'table' ? 'tables' : (node.type === 'materialized-view' ? 'materializedViews' : 'views'),
});
onDoubleClick(null, node);
return;
}
if (node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
setSelectedKeys([node.key]);
selectedNodesRef.current = [node];
scrollSidebarTreeToKey(node.key);
onDoubleClick(null, node);
}
}, [activeContext, activeTab, addTab, closeV2CommandSearch, selectConnectionFromRail, setActiveContext]);
const handleV2CommandSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setV2CommandActiveIndex((prev) => {
if (commandSearchFlatItems.length === 0) return 0;
return Math.min(prev + 1, commandSearchFlatItems.length - 1);
});
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setV2CommandActiveIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (event.key === 'Enter') {
if (!shouldRunV2CommandSearchEnter({
key: event.key,
isComposing: event.nativeEvent.isComposing,
keyCode: event.nativeEvent.keyCode,
activeItemCount: commandSearchFlatItems.length,
})) {
return;
}
event.preventDefault();
runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeV2CommandSearch();
}
};
expandConnectionFromRailRef.current = (connectionId: string) => {
const conn = connections.find((item) => item.id === connectionId);
if (conn) {
selectConnectionFromRail(conn);
}
};
const getNodeMenuItems = (node: any): MenuProps['items'] => buildSidebarLegacyNodeMenuItems(node, {
addTab,
getMetadataDialect,
handleV2DatabaseContextMenuAction,
isPostgresSchemaDialect,
handleExportSchemaSQL,
openRenameSchemaModal,
loadTables,
getDatabaseNodeRef,
handleDeleteSchema,
tableSortPreference,
isStructureOnlyDbType,
openNewTableDesign,
handleTableGroupSortAction,
openCreateView,
openCreateStarRocksMaterializedView,
openCreateRoutine,
createTagForm,
setRenameViewTarget,
setIsCreateTagModalOpen,
removeConnectionTag,
setExpandedKeys,
setLoadedKeys,
loadingNodesRef,
loadDatabases,
buildConnectionRootRedisCommandTabTitle,
buildConnectionRootRedisMonitorTabTitle,
onEditConnection,
handleDuplicateConnection,
disconnectConnectionNode,
deleteConnectionNode,
connectionTags,
moveConnectionToTag,
setTargetConnection,
setIsCreateDbModalOpen,
buildConnectionRootQueryTabTitle,
handleRunSQLFile,
openCreateStarRocksExternalCatalog,
openEditView,
renameViewForm,
setIsRenameViewModalOpen,
handleDropView,
onDoubleClick,
openViewDefinition,
openRoutineDefinition,
openEditRoutine,
handleDropRoutine,
openEventDefinition,
resolveMessagePublishTarget,
openMessagePublishModal,
openDesign,
openCreateStarRocksRollup,
handleCopyTableName,
handleCopyStructure,
handleExport,
setRenameTableTarget,
renameTableForm,
setIsRenameTableModalOpen,
handleTableDataDangerAction,
handleDeleteTable,
openExportDialog,
isSavedQueryUnmatched,
connections,
handleRebindSavedQuery,
openRenameSavedQueryModal,
resolveSavedQueryDisplayName,
deleteQuery,
treeDataRef,
setTreeData,
handleAddExternalSQLDirectory,
openCreateExternalSQLFileModal,
openCreateExternalSQLDirectoryModal,
openRenameExternalSQLDirectoryModal,
handleRefreshExternalSQLDirectory,
handleDeleteExternalSQLDirectory,
handleRemoveExternalSQLDirectory,
openExternalSQLFile,
openRenameExternalSQLFileModal,
handleDeleteExternalSQLFile,
extractObjectName,
});
const titleRender = (node: any) => {
let status: 'success' | 'error' | 'default' = 'default';
if (node.type === 'connection' || node.type === 'database') {
if (connectionStates[node.key] === 'success') status = 'success';
else if (connectionStates[node.key] === 'error') status = 'error';
}
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
isV2Ui
? <span className={`gn-v2-tree-status is-${status}`} aria-hidden="true" />
: <Badge status={status} style={{ marginLeft: 4, marginRight: 8 }} />
) : null;
const displayTitle = String(node.title ?? '');
const dragText = resolveSidebarObjectDragText(node);
let hoverTitle = displayTitle;
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view' || node.type === 'db-event') {
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || node?.dataRef?.eventName || '').trim();
const conn = node?.dataRef as SavedConnection | undefined;
if (rawTableName && shouldHideSchemaPrefix(conn)) {
if (splitQualifiedName(rawTableName).schemaName) {
hoverTitle = rawTableName;
}
}
} else if (node.type === 'object-group') {
const objectGroupTitle = resolveV2ObjectGroupTitle(node);
if (objectGroupTitle) {
hoverTitle = objectGroupTitle;
}
} else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
hoverTitle = String(node?.dataRef?.path || displayTitle);
}
if (node.type === 'jvm-mode') {
return (
<span
title={hoverTitle}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}
>
<JVMModeBadge
mode={String(node?.dataRef?.providerMode || displayTitle)}
label={displayTitle}
reason={String(node?.dataRef?.reason || '').trim() || undefined}
/>
</span>
);
}
if (node.type === 'external-sql-root') {
const externalSqlRootTitle = t('sidebar.external_sql.root');
const addSqlDirectoryLabel = t('sidebar.menu.add_sql_directory');
return (
<span
title={externalSqlRootTitle}
className="gn-v2-tree-external-root"
>
<span
className="gn-v2-tree-title"
data-node-type={node.type}
data-sidebar-node-key={String(node.key || '')}
data-sidebar-node-type={String(node.type || '')}
>
<span className="gn-v2-tree-label">
{statusBadge}
{externalSqlRootTitle}
</span>
</span>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
title={addSqlDirectoryLabel}
aria-label={addSqlDirectoryLabel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleAddExternalSQLDirectory(node);
}}
className="gn-v2-tree-external-root-action"
/>
</span>
);
}
if (isV2Ui) {
return renderV2TreeTitle(node, hoverTitle, statusBadge);
}
if (dragText) {
return (
<span
title={hoverTitle}
draggable
onDragStart={(event) => {
snapshotTreeSelectionBeforeDrag();
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
setIsTreeDragging(true);
event.stopPropagation();
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', dragText);
event.dataTransfer.setData(
SIDEBAR_SQL_EDITOR_DRAG_MIME,
encodeSidebarSqlEditorDragPayload({
text: dragText,
nodeType: node.type,
connectionId: String(node?.dataRef?.id || ''),
dbName: String(node?.dataRef?.dbName || ''),
}),
);
}}
onDragEnd={() => {
restoreTreeSelectionAfterDrag();
setIsTreeDragging(false);
}}
>
{statusBadge}{displayTitle}
</span>
);
}
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
};
const handleDrop = (info: any) => {
setIsTreeDragging(false);
const dropPosition = normalizeSidebarTreeRelativeDropPosition(
Number(info.dropPosition || 0),
info?.node?.pos,
);
const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);
const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);
const insertBefore = resolveSidebarDropInsertBefore(dropPosition, dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null);
const dragNode = info.dragNode;
const dropNode = domDropNode && domDropNode.key === String(info?.node?.key || '')
? info.node
: (domDropNode
? findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key) || info.node
: info.node);
const getDropRootToken = (node: any): string => {
if (!node) return '';
if (node.type === 'tag') {
return buildSidebarRootTagToken(String(node?.dataRef?.id || ''));
}
if (node.type === 'connection') {
const groupedTagId = connectionTags.find((tag) =>
tag.connectionIds.includes(String(node.key)),
)?.id || '';
return groupedTagId
? buildSidebarRootTagToken(groupedTagId)
: buildSidebarRootConnectionToken(String(node.key));
}
return '';
};
// Root tag or ungrouped connection reordering
if (dragNode.type === 'tag') {
if (dropNode.type === 'tag' || dropNode.type === 'connection') {
const currentTagOrder = connectionTags.map(t => t.id);
const dragTagId = dragNode.dataRef.id;
const dropTagId = dropNode.type === 'tag'
? dropNode.dataRef.id
: (connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || '');
const dragRootToken = buildSidebarRootTagToken(String(dragTagId));
const dropRootToken = getDropRootToken(dropNode);
if (dropRootToken && dropRootToken !== dragRootToken) {
if (dropTagId) {
const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({
currentTagOrder,
dragTagId,
dropTagId,
relativeDropPosition: dropPosition,
fallbackInsertBefore: insertBefore,
metrics: dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null,
});
reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore);
} else {
reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore);
}
return;
}
const newOrder = currentTagOrder.filter(id => id !== dragTagId);
let insertIndex = newOrder.length;
if (dropTagId) {
const dropIndex = newOrder.indexOf(dropTagId);
const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({
currentTagOrder,
dragTagId,
dropTagId,
relativeDropPosition: dropPosition,
fallbackInsertBefore: insertBefore,
metrics: dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null,
});
if (resolvedInsertBefore) {
insertIndex = dropIndex;
} else {
insertIndex = dropIndex + 1;
}
} else {
// Dropped onto an ungrouped root connection, usually meaning moving to the end of tags
// Since tags are always displayed before ungrouped connections, just put it at the end
insertIndex = newOrder.length;
}
newOrder.splice(insertIndex, 0, dragTagId);
reorderTags(newOrder);
}
return;
}
if (dragNode.type === 'connection') {
const dragTagId = connectionTags.find((tag) =>
tag.connectionIds.includes(String(dragNode.key)),
)?.id || '';
const dragIsUngroupedRoot = !dragTagId;
const dropRootToken = getDropRootToken(dropNode);
if (dragIsUngroupedRoot && dropNode.type === 'connection' && dropRootToken) {
reorderSidebarRoot(
buildSidebarRootConnectionToken(String(dragNode.key)),
dropRootToken,
insertBefore,
);
return;
}
}
// Connection moving to tag (any drop position on a tag node counts as "into")
if (dragNode.type === 'connection' && dropNode.type === 'tag') {
moveConnectionToTag(dragNode.key, dropNode.dataRef.id);
return;
}
// Connection reordering against another connection
if (dragNode.type === 'connection' && dropNode.type === 'connection') {
const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key));
reorderConnections(
String(dragNode.key),
String(dropNode.key),
targetTag?.id || null,
insertBefore,
);
return;
}
};
const onRightClick = ({ event, node }: any) => {
if (isV2Ui && node?.type === 'v2-table-section') {
event.preventDefault();
event.stopPropagation();
return;
}
if (isV2Ui && node?.type === 'connection') {
openV2ConnectionContextMenu(event, node);
return;
}
if (isV2Ui && node?.type === 'database') {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-database',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
if (
isV2Ui
&& node?.type === 'object-group'
&& node?.dataRef?.groupKey === 'schema'
&& isPostgresSchemaDialect(getMetadataDialect(node.dataRef as SavedConnection))
&& String(node?.dataRef?.schemaName || '').trim()
) {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-schema',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
if (isV2Ui && node?.type === 'object-group' && node?.dataRef?.groupKey === 'tables') {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-table-group',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
if (isV2Ui && node?.type === 'table') {
const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY);
setContextMenu({
x: position.x,
y: position.y,
sourceX: event.clientX,
sourceY: event.clientY,
items: [],
kind: 'v2-table',
node,
rootClassName: 'gn-v2-table-context-menu-popup',
overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' },
maxHeight: position.maxHeight,
});
return;
}
const items = getNodeMenuItems(node);
if (items && items.length > 0) {
setContextMenu({
x: event.clientX,
y: event.clientY,
items
});
}
};
const v2RailObjectActionsLabel = t('sidebar.rail.object_actions');
const v2RailSystemActionsLabel = t('sidebar.rail.system_actions');
const v2NewGroupLabel = t('sidebar.action.new_group');
const v2BatchTablesLabel = t('sidebar.action.batch_tables');
const v2BatchDatabasesLabel = t('sidebar.action.batch_databases');
const v2OpenExternalSqlFileLabel = t('sidebar.sql_file_exec.title');
const v2LocateCurrentTableLabel = t('sidebar.action.locate_current_table');
const v2LocateCurrentTableUnavailableLabel = t('sidebar.message.locate_current_table_unavailable');
const v2AiAssistantLabel = t('app.sidebar.ai_assistant');
const v2ToolsLabel = t('app.sidebar.tools');
const v2SettingsLabel = t('app.sidebar.settings');
const v2ActiveConnectionHeaderLabel = t('sidebar.active_connection.current_host_database');
const v2NoDatabaseSelectedLabel = t('sidebar.active_connection.no_database_selected');
const v2ConnectionActionsLabel = t('sidebar.active_connection.actions');
const v2CommandSearchLabel = t('sidebar.command_search.label');
const v2CommandSearchPlaceholder = t('sidebar.command_search.placeholder');
const v2CommandSearchPanelProps: SidebarSearchPanelProps<V2CommandSearchItem> = {
isOpen: isV2CommandSearchOpen,
searchValue: v2CommandSearchValue,
activeIndex: v2CommandActiveIndex,
label: v2CommandSearchLabel,
placeholder: v2CommandSearchPlaceholder,
persistedFilter: v2PersistedSidebarFilter,
persistentFilterEnabled: v2CommandSearchPersistentFilterEnabled,
aiMode: v2CommandSearchAiMode,
objectMode: v2CommandSearchObjectMode,
flatItems: commandSearchFlatItems,
sections: {
goTo: filteredCommandSearchTreeItems,
ai: commandSearchAiItem,
actions: filteredCommandSearchActionItems,
recent: filteredCommandSearchRecentItems,
},
inputRef: commandSearchInputRef,
handlers: {
onSearchValueChange: handleV2CommandSearchValueChange,
onKeyDown: handleV2CommandSearchKeyDown,
onClose: closeV2CommandSearch,
onItemSelect: (item: V2CommandSearchItem) => runCommandSearchItem(item),
onItemHover: (key: string) => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === key)),
onTogglePersistentFilter: toggleV2CommandSearchPersistentFilter,
onResetFilter: resetV2SidebarFilter,
},
};
// V2 Connection Rail 子组件 props从原 renderV2ConnectionRail 抽出,保留所有原行为)
const v2ConnectionRailProps = {
labels: {
railSystemActions: v2RailSystemActionsLabel,
railObjectActions: v2RailObjectActionsLabel,
newGroup: v2NewGroupLabel,
batchTables: v2BatchTablesLabel,
batchDatabases: v2BatchDatabasesLabel,
openExternalSqlFile: v2OpenExternalSqlFileLabel,
locateCurrentTable: v2LocateCurrentTableLabel,
locateCurrentTableUnavailable: v2LocateCurrentTableUnavailableLabel,
aiAssistant: v2AiAssistantLabel,
tools: v2ToolsLabel,
settings: v2SettingsLabel,
},
handlers: {
openCreateTagModal: () => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); },
openBatchTableExport: () => openBatchTableExportWorkbench(),
openBatchDatabaseExport: () => openBatchDatabaseExportWorkbench(),
openExternalSqlFile: handleOpenSQLFileFromToolbar,
locateActiveTab: handleLocateActiveTabInSidebar,
toggleAI: onToggleAI ?? (() => {}),
openTools: onOpenTools ?? (() => {}),
openSettings: onOpenSettings ?? (() => {}),
},
canLocateActiveTab,
};
return (
<div className={isV2Ui ? 'gn-v2-sidebar-redesign' : undefined} style={{ display: 'flex', height: '100%', minHeight: 0 }}>
{exportProgressModal}
{isV2Ui && <SidebarConnectionRail {...v2ConnectionRailProps} />}
<div className={isV2Ui ? 'gn-v2-object-explorer' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0, flex: 1 }}>
{isV2Ui && (
<div className="gn-v2-active-connection-header" data-object-count={activeConnectionObjectCount}>
<div className="gn-v2-active-connection-trigger" aria-label={v2ActiveConnectionHeaderLabel}>
<span className={`gn-v2-live-dot is-${activeConnection ? buildRailConnectionStatus(activeConnection.id) : 'idle'}`} />
<div className="gn-v2-active-connection-copy">
<strong>{activeConnectionDisplayName}</strong>
<span>{activeDatabaseDisplayName || v2NoDatabaseSelectedLabel}</span>
</div>
</div>
<div className="gn-v2-active-connection-actions">
{onCreateConnection && (
<Tooltip title={t('connection.new')}>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
aria-label={t('connection.new')}
data-gonavi-create-connection-action="true"
onClick={onCreateConnection}
/>
</Tooltip>
)}
<Tooltip title={v2ConnectionActionsLabel}>
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={v2ConnectionActionsLabel}
disabled={!activeConnection}
onClick={(event) => {
if (activeConnection) {
openV2ConnectionContextMenu(event, activeConnection);
}
}}
/>
</Tooltip>
</div>
</div>
)}
<div className={isV2Ui ? 'gn-v2-explorer-search' : undefined} style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
{isV2Ui && !v2UseLegacySidebarFilter ? (
<div className="gn-v2-explorer-command-row" data-v2-sidebar-search-mode="command">
<button
type="button"
className="gn-v2-explorer-command-trigger"
onClick={() => {
openV2CommandSearch();
onFocusCommandSearch?.();
}}
aria-label={v2CommandSearchLabel}
>
<SearchOutlined />
<span>{v2PersistedSidebarFilter || v2CommandSearchPlaceholder}</span>
{focusSidebarSearchShortcutTokens.length > 0 ? (
<span className="gn-v2-search-shortcut" aria-hidden="true">
{focusSidebarSearchShortcutTokens.map((token, index) => (
<kbd key={`${token}-${index}`}>{token}</kbd>
))}
</span>
) : null}
</button>
<Tooltip title={v2PersistedSidebarFilter ? t('sidebar.command_search.reset_filter') : t('sidebar.command_search.no_synced_filter')}>
<button
type="button"
className="gn-v2-explorer-filter-action"
aria-label={t('sidebar.command_search.reset_filter')}
disabled={!v2PersistedSidebarFilter}
onClick={resetV2SidebarFilter}
>
<ReloadOutlined />
</button>
</Tooltip>
</div>
) : isV2Ui ? (
<div className="gn-v2-explorer-legacy-filter-row" data-v2-sidebar-search-mode="filter">
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
value={searchValue}
placeholder={t('sidebar.search.placeholder')}
onChange={onSearch}
size="small"
prefix={<SearchOutlined />}
/>
<Tooltip title={searchValue ? t('sidebar.command_search.reset_filter') : t('sidebar.command_search.no_filter_content')}>
<button
type="button"
className="gn-v2-explorer-filter-action"
aria-label={t('sidebar.command_search.reset_filter')}
disabled={!searchValue}
onClick={resetV2SidebarFilter}
>
<ReloadOutlined />
</button>
</Tooltip>
</div>
) : (
<Input
{...noAutoCapInputProps}
ref={searchInputRef}
placeholder={t('sidebar.search.placeholder')}
onChange={onSearch}
size="small"
prefix={<SearchOutlined style={{ color: darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)' }} />}
style={{
borderRadius: 6,
border: 'none',
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)',
boxShadow: 'none',
padding: '4px 8px',
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)',
}}
suffix={(
<Popover
content={searchScopePopoverContent}
trigger="click"
placement="bottomRight"
open={isSearchScopePopoverOpen}
onOpenChange={setIsSearchScopePopoverOpen}
styles={{ body: { padding: 0, borderRadius: 16, overflow: 'hidden' } }}
>
<Tooltip title={t('sidebar.command_search.scope.tooltip', { scope: searchScopeSummary })}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
background: isSearchScopePopoverOpen
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)')
: 'transparent',
transition: 'background 0.2s',
color: searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'),
}}
onMouseEnter={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)';
}
}}
onMouseLeave={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)');
}
}}
>
<FilterOutlined style={{ fontSize: 13 }} />
<span style={{ fontSize: 12, fontWeight: 500 }}>
{searchScopes.includes('smart') ? t('sidebar.command_search.scope.compact_smart') : searchScopes.length}
</span>
</div>
</Tooltip>
</Popover>
)}
/>
)}
</div>
{isV2Ui && (
<div className="gn-v2-explorer-filter-tabs" aria-label={t('sidebar.command_search.object_kind.filter_aria')}>
{V2_EXPLORER_FILTER_OPTIONS.map((item) => (
<button
key={item.key}
type="button"
className={v2ExplorerFilter === item.key ? 'is-active' : undefined}
aria-pressed={v2ExplorerFilter === item.key}
onClick={() => setV2ExplorerFilter(item.key)}
>
{t(item.labelKey)}
</button>
))}
</div>
)}
{/* Toolbar */}
{!isV2Ui && (
<div data-sidebar-legacy-toolbar="true" style={legacyToolbarStyle}>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title="新建组">
<Button
size="small"
type="text"
icon={<FolderOpenOutlined />}
aria-label="新建组"
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="批量操作表">
<Button
size="small"
type="text"
icon={<TableOutlined />}
aria-label="批量操作表"
data-sidebar-batch-table-action="true"
onClick={() => openBatchTableExportWorkbench()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title="批量操作库">
<Button
size="small"
type="text"
icon={<DatabaseOutlined />}
aria-label="批量操作库"
data-sidebar-batch-database-action="true"
onClick={() => openBatchDatabaseExportWorkbench()}
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 ? '定位当前标签页' : '当前标签页没有可定位的内容'}>
<span style={legacyToolbarDisabledWrapStyle}>
<Button
size="small"
type="text"
icon={<AimOutlined />}
aria-label="定位当前标签页"
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
ref={treeRef}
showIcon
draggable={{
icon: false,
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
onDragStart={() => {
snapshotTreeSelectionBeforeDrag();
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
setIsTreeDragging(true);
}}
onDragEnter={() => {
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
setIsTreeDragging(true);
}}
onDragEnd={() => {
restoreTreeSelectionAfterDrag();
setIsTreeDragging(false);
}}
onDrop={handleDrop}
loadData={onLoadData}
treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}
onDoubleClick={onDoubleClick}
onSelect={onSelect}
titleRender={titleRender}
expandedKeys={expandedKeys}
onExpand={onExpand}
loadedKeys={loadedKeys}
onLoad={setLoadedKeys}
autoExpandParent={autoExpandParent}
selectedKeys={selectedKeys}
blockNode
height={effectiveTreeHeight}
scrollWidth={isV2Ui ? v2TreeHorizontalScrollWidth : undefined}
onRightClick={onRightClick}
/>
</div>
</div>
{isV2Ui && (
<div className="gn-v2-sidebar-log-footer">
<button type="button" className="gn-v2-sidebar-log-button" onClick={onToggleLogPanel}>
<BarsOutlined />
<span>SQL </span>
<small>{sqlLogCount.toLocaleString()}</small>
</button>
</div>
)}
</div>
<SidebarSearchPanel {...v2CommandSearchPanelProps} />
{contextMenu?.kind && typeof document !== 'undefined' && createPortal(
<div
ref={contextMenuPortalRef}
className={`gn-v2-sidebar-context-menu-portal ${contextMenu.rootClassName || ''}`}
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 10000,
width: contextMenu.overlayStyle?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH,
maxWidth: contextMenu.overlayStyle?.maxWidth ?? 'calc(100vw - 24px)',
['--gn-v2-context-menu-max-height' as any]: `${contextMenu.maxHeight ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT}px`,
}}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
{renderV2SidebarContextMenuContent(contextMenu)}
</div>,
document.body,
)}
{contextMenu && !contextMenu.kind && (
<Dropdown
menu={{ items: contextMenu.items }}
open={true}
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
trigger={['contextMenu']}
rootClassName={contextMenu.rootClassName}
overlayStyle={contextMenu.overlayStyle}
>
<div style={{ position: 'fixed', left: contextMenu.x, top: contextMenu.y, width: 1, height: 1 }} />
</Dropdown>
)}
<SidebarEntityModals
connections={connections}
connectionTags={connectionTags}
modalPanelStyle={modalPanelStyle}
modalSectionStyle={modalSectionStyle}
modalScrollSectionStyle={modalScrollSectionStyle}
renderSidebarModalTitle={renderSidebarModalTitle}
isCreateTagModalOpen={isCreateTagModalOpen}
setIsCreateTagModalOpen={setIsCreateTagModalOpen}
createTagForm={createTagForm}
renameViewTarget={renameViewTarget}
updateConnectionTag={updateConnectionTag}
addConnectionTag={addConnectionTag}
moveConnectionToTag={moveConnectionToTag}
isCreateDbModalOpen={isCreateDbModalOpen}
setIsCreateDbModalOpen={setIsCreateDbModalOpen}
createDbForm={createDbForm}
handleCreateDatabase={handleCreateDatabase}
isCreateSchemaModalOpen={isCreateSchemaModalOpen}
setIsCreateSchemaModalOpen={setIsCreateSchemaModalOpen}
createSchemaForm={createSchemaForm}
createSchemaTarget={createSchemaTarget}
setCreateSchemaTarget={setCreateSchemaTarget}
handleCreateSchema={handleCreateSchema}
isRenameSchemaModalOpen={isRenameSchemaModalOpen}
setIsRenameSchemaModalOpen={setIsRenameSchemaModalOpen}
renameSchemaForm={renameSchemaForm}
renameSchemaTarget={renameSchemaTarget}
setRenameSchemaTarget={setRenameSchemaTarget}
handleRenameSchema={handleRenameSchema}
isRenameDbModalOpen={isRenameDbModalOpen}
setIsRenameDbModalOpen={setIsRenameDbModalOpen}
renameDbForm={renameDbForm}
renameDbTarget={renameDbTarget}
setRenameDbTarget={setRenameDbTarget}
handleRenameDatabase={handleRenameDatabase}
isRenameTableModalOpen={isRenameTableModalOpen}
setIsRenameTableModalOpen={setIsRenameTableModalOpen}
renameTableForm={renameTableForm}
renameTableTarget={renameTableTarget}
setRenameTableTarget={setRenameTableTarget}
handleRenameTable={handleRenameTable}
isRenameViewModalOpen={isRenameViewModalOpen}
setIsRenameViewModalOpen={setIsRenameViewModalOpen}
renameViewForm={renameViewForm}
setRenameViewTarget={setRenameViewTarget}
handleRenameView={handleRenameView}
isRenameSavedQueryModalOpen={isRenameSavedQueryModalOpen}
setIsRenameSavedQueryModalOpen={setIsRenameSavedQueryModalOpen}
renameSavedQueryForm={renameSavedQueryForm}
renameSavedQueryTarget={renameSavedQueryTarget}
setRenameSavedQueryTarget={setRenameSavedQueryTarget}
handleRenameSavedQuery={handleRenameSavedQuery}
/>
<ExternalSQLFileModal {...externalSQLFileModalProps} />
<SidebarBatchExportModals
connections={connections}
modalPanelStyle={modalPanelStyle}
modalSectionStyle={modalSectionStyle}
modalScrollSectionStyle={modalScrollSectionStyle}
modalHintTextStyle={modalHintTextStyle}
darkMode={darkMode}
tableModalTitle={renderSidebarModalTitle(<TableOutlined />, "批量操作表", "按对象批量导出结构、数据或完整备份。")}
databaseModalTitle={renderSidebarModalTitle(<DatabaseOutlined />, "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")}
isBatchModalOpen={isBatchModalOpen}
setIsBatchModalOpen={setIsBatchModalOpen}
selectedConnection={selectedConnection}
selectedDatabase={selectedDatabase}
availableDatabases={availableDatabases}
batchTables={batchTables}
checkedTableKeys={checkedTableKeys}
setCheckedTableKeys={setCheckedTableKeys}
batchFilterKeyword={batchFilterKeyword}
setBatchFilterKeyword={setBatchFilterKeyword}
batchFilterType={batchFilterType}
setBatchFilterType={setBatchFilterType}
batchSelectionScope={batchSelectionScope}
setBatchSelectionScope={setBatchSelectionScope}
filteredBatchObjects={filteredBatchObjects}
groupedBatchObjects={groupedBatchObjects}
selectionScopeTargetKeys={selectionScopeTargetKeys}
handleConnectionChange={handleConnectionChange}
handleDatabaseChange={handleDatabaseChange}
handleBatchClear={handleBatchClear}
handleBatchExport={handleBatchExport}
handleCheckAll={handleCheckAll}
handleInvertSelection={handleInvertSelection}
isBatchDbModalOpen={isBatchDbModalOpen}
setIsBatchDbModalOpen={setIsBatchDbModalOpen}
selectedDbConnection={selectedDbConnection}
batchDatabases={batchDatabases}
checkedDbKeys={checkedDbKeys}
setCheckedDbKeys={setCheckedDbKeys}
handleDbConnectionChange={handleDbConnectionChange}
handleBatchDbExport={handleBatchDbExport}
handleCheckAllDb={handleCheckAllDb}
handleInvertSelectionDb={handleInvertSelectionDb}
/>
<SQLFileExecutionModal
title={v2OpenExternalSqlFileLabel}
modalPanelStyle={modalPanelStyle}
{...sqlFileExecutionModalProps}
/>
<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;