diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 18eac774..689b73a0 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -58,6 +58,16 @@ import { formatV2TableContextMenuSize, } from './V2TableContextMenu'; +const readSourceFile = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +const readSidebarSource = () => [ + readSourceFile('./Sidebar.tsx'), + readSourceFile('./sidebar/sidebarHelpers.ts'), + readSourceFile('./sidebar/SidebarConnectionRail.tsx'), + readSourceFile('./sidebar/SidebarSearchPanel.tsx'), + readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), +].join('\n'); +const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); + const mocks = vi.hoisted(() => ({ noop: vi.fn(), state: { @@ -270,7 +280,7 @@ describe('Sidebar locate toolbar', () => { }); it('wires tree expand and double-click expansion to lazy loading', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain('if (hasSidebarLazyChildren(children)) return;'); expect(source).toContain('if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info))'); @@ -510,7 +520,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the sidebar memoized so parent-only button state does not repaint the tree', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain('}> = React.memo(({'); }); @@ -577,7 +587,7 @@ describe('Sidebar locate toolbar', () => { }); it('releases backend database connections when disconnecting a sidebar connection', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const disconnectSource = source.slice( source.indexOf('const releaseConnectionResources = async'), source.indexOf('const deleteConnectionNode ='), @@ -599,7 +609,7 @@ describe('Sidebar locate toolbar', () => { }); it('passes the exact tree key when locating a command-search object node', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const commandSearchRunSource = source.slice( source.indexOf("if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view')"), source.indexOf("if (node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine')"), @@ -609,7 +619,7 @@ describe('Sidebar locate toolbar', () => { }); it('wires external SQL directory file actions to dedicated Wails APIs', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const loadTablesSource = source.slice( source.indexOf('const loadTables = async'), source.indexOf('const locateObjectInSidebarRef'), @@ -644,7 +654,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the legacy sidebar toolbar on a stable five-column grid layout', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const markup = renderSidebarMarkup(); expect(markup).toContain('data-sidebar-legacy-toolbar="true"'); @@ -659,7 +669,7 @@ describe('Sidebar locate toolbar', () => { it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => { const markup = renderSidebarMarkup({ uiVersion: 'v2', sqlLogCount: 2341, onCreateConnection: mocks.noop }); - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(markup).toContain('gn-v2-sidebar-redesign'); expect(markup).toContain('gn-v2-connection-rail'); @@ -685,7 +695,8 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("const [v2ExplorerFilter, setV2ExplorerFilter] = useState('all');"); expect(source).toContain("const v2SidebarSearchMode = appearance.v2SidebarSearchMode ?? 'command';"); expect(source).toContain('const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true;'); - expect(source).toContain('handleV2CommandSearchValueChange(event.target.value)'); + expect(source).toContain('onSearchValueChange: handleV2CommandSearchValueChange,'); + expect(source).toContain('handlers.onSearchValueChange(event.target.value)'); expect(source).toContain('toggleV2CommandSearchPersistentFilter'); expect(source).toContain('gn-v2-command-filter-switch'); expect(source).toContain("window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true)"); @@ -708,9 +719,9 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('aria-label="工具"'); expect(markup).toContain('data-gonavi-open-tools-action="true"'); expect(markup).toContain('aria-label="设置"'); - expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder)'); - expect(source).toContain("kind: 'v2-connection-group'"); - expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}'); + expect(source).toContain('export const buildV2RailConnectionGroups = ('); + expect(source).toContain("if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node);"); + expect(source).toContain('openV2ConnectionContextMenu(event, node);'); expect(source).toContain("kind: 'v2-connection'"); expect(source).toContain('resolveSidebarContextMenuPosition(event.clientX, event.clientY)'); expect(source).toContain('contextMenuPortalRef'); @@ -758,7 +769,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes the v2 command search scope shell and object filters through catalog keys', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const objectKindSource = source.slice( source.indexOf('const V2_EXPLORER_FILTER_OPTIONS'), source.indexOf('const V2_EXPLORER_FILTER_GROUP_KEYS'), @@ -863,7 +874,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the v2 command search footer hints tied to real prefix actions', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';"); expect(source).toContain("const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';"); @@ -875,7 +886,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders v2 command action shortcuts from the shared shortcut options', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform)"); expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform)"); @@ -909,7 +920,7 @@ describe('Sidebar locate toolbar', () => { it('keeps v2 tree status dots circular while using virtual horizontal scroll for long labels', () => { const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8'); expect(source).toContain('gn-v2-tree-status is-${status}'); @@ -1071,7 +1082,7 @@ describe('Sidebar locate toolbar', () => { }; const markup = renderSidebarMarkup({ uiVersion: 'v2' }); - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("if (v2ExplorerFilter === 'all') {"); expect(source).toContain('gn-v2-tree-connection-copy'); @@ -1079,7 +1090,7 @@ describe('Sidebar locate toolbar', () => { }); it('reorders dragged connections instead of only moving them between groups', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8'); expect(source).toContain('const reorderConnections = useStore(state => state.reorderConnections);'); @@ -1093,27 +1104,30 @@ describe('Sidebar locate toolbar', () => { }); it('reorders dragged tags relative to grouped connections instead of always appending them', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''"); expect(source).toContain('const dropTagId = dropNode.type === \'tag\''); expect(source).toContain('if (dropTagId) {'); }); - it('wires v2 rail root dragging through the shared sidebar root order action', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + it('wires v2 tree root dragging through the shared sidebar root order action', () => { + const source = readSidebarSource(); expect(source).toContain('const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);'); - expect(source).toContain('const [draggingV2RailRootToken, setDraggingV2RailRootToken] = useState(\'\');'); expect(source).toContain('const treeDragSelectSuppressUntilRef = useRef(0);'); expect(source).toContain('const treeDragSelectionSnapshotRef = useRef<'); expect(source).toContain('snapshotTreeSelectionBeforeDrag();'); expect(source).toContain('restoreTreeSelectionAfterDrag();'); expect(source).toContain('if (Date.now() < treeDragSelectSuppressUntilRef.current) {'); - expect(source).toContain('handleV2RailRootDrop('); - expect(source).toContain('draggable'); - expect(source).toContain('setDraggingV2RailRootToken(rootToken);'); - expect(source).toContain('reorderSidebarRoot(sourceToken, targetToken, insertBefore);'); + expect(source).toContain('const getDropRootToken = (node: any): string => {'); + expect(source).toContain("return buildSidebarRootTagToken(String(node?.dataRef?.id || ''));"); + expect(source).toContain(': buildSidebarRootConnectionToken(String(node.key));'); + expect(source).toContain('const dragRootToken = buildSidebarRootTagToken(String(dragTagId));'); + expect(source).toContain('reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore);'); + expect(source).toContain('reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore);'); + expect(source).toContain('buildSidebarRootConnectionToken(String(dragNode.key))'); + expect(source).toContain('onDrop={handleDrop}'); }); it('normalizes rc-tree absolute drop positions back to relative positions', () => { @@ -1348,7 +1362,7 @@ describe('Sidebar locate toolbar', () => { }); it('adds rename to the saved query context menu', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const renameHandlerStart = source.indexOf('const handleRenameSavedQuery = async () =>'); const renameHandlerEnd = source.indexOf('const openRoutineDefinition', renameHandlerStart); const savedQueryMenuStart = source.indexOf('// 已存查询节点的右键菜单'); @@ -1668,7 +1682,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the v2 table pin action on sidebar table rows', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); expect(source).toContain('data-v2-sidebar-table-pin-action="true"'); @@ -1681,7 +1695,7 @@ describe('Sidebar locate toolbar', () => { }); it('splits v2 sidebar pinned tables into a dedicated table section', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const sectionBuilderSourceStart = source.indexOf('export const buildV2SidebarTableSectionedChildren = ('); const sectionBuilderSourceEnd = source.indexOf('export const buildSidebarTableChildrenForUi = ('); const sectionBuilderSource = source.slice(sectionBuilderSourceStart, sectionBuilderSourceEnd); @@ -1720,7 +1734,7 @@ describe('Sidebar locate toolbar', () => { { title: 'orders', key: 'orders', type: 'table' as const, dataRef: { pinnedSidebarTable: true } }, { title: 'users', key: 'users', type: 'table' as const, dataRef: { pinnedSidebarTable: false } }, ]; - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(buildSidebarTableChildrenForUi('conn-main-tables', tableNodes, false)).toBe(tableNodes); expect(buildSidebarTableChildrenForUi('conn-main-tables', tableNodes, true).map((node) => node.title)).toEqual([ @@ -1742,7 +1756,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders v2 table section labels as tree children instead of group header badges', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); expect(source).toContain("node.type === 'v2-table-section'"); @@ -2019,7 +2033,7 @@ describe('Sidebar locate toolbar', () => { }); it('routes v2 database context menu shell copy through i18n wrappers in Sidebar', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const createSchemaSource = source.slice( source.indexOf('const openCreateSchemaModal = (node: any) => {'), source.indexOf('const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {'), @@ -2153,7 +2167,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes v2 connection shell fallbacks and group controls without changing raw names', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const menuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8'); expect(source).toContain("connectionName={String(conn?.name || node.title || t('connection.unnamed'))}"); @@ -2162,9 +2176,11 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("title: String(node.title || dataRef.dbName || t('database.unnamed'))"); expect(source).toContain("meta: conn?.name || dataRef.id || t('database.label')"); expect(source).toContain("const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected');"); - expect(source).toContain("const groupTitle = group.name || t('connection.sidebar.group.untitled');"); - expect(source).toContain("title={t('connection.sidebar.group.meta', { count: group.connections.length.toLocaleString() })}"); - expect(source).toContain("aria-label={t(collapsed ? 'connection.sidebar.group.expandAria' : 'connection.sidebar.group.collapseAria', { name: groupTitle })}"); + expect(source).toContain("name: tag.name || t('connection.sidebar.group.untitled'),"); + expect(source).toContain('groupName={group.name}'); + expect(source).toContain('count={group.connections.length}'); + expect(menuSource).toContain("title={groupName || t('connection.sidebar.group.untitled')}"); + expect(menuSource).toContain("meta={t('connection.sidebar.group.meta', { count: count.toLocaleString() })}"); expect(menuSource).toContain("pill={t('connection.sidebar.group.badge')}"); expect(menuSource).toContain("title: t('connection.sidebar.group.edit')"); expect(menuSource).toContain("title: t('connection.sidebar.group.delete')"); @@ -2172,7 +2188,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes v2 connection group modals and delete confirmation shells while keeping raw group names', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("title: t('connection.sidebar.group.deleteConfirmTitle')"); expect(source).toContain("content: t('connection.sidebar.group.deleteConfirmContent', { name: tag.name })"); @@ -2201,7 +2217,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes legacy sidebar connection duplicate disconnect and delete copy', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("t('connection.sidebar.menu.copy')"); expect(source).toContain("t('connection.sidebar.menu.disconnect')"); @@ -2219,7 +2235,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes the sidebar table pin action title and aria-label via i18n keys', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const tablePinActionStart = source.indexOf("const tablePinAction = node.type === 'table' ? ("); const tablePinActionEnd = source.indexOf('aria-pressed=', tablePinActionStart); const tablePinActionSource = source.slice(tablePinActionStart, tablePinActionEnd); @@ -2238,7 +2254,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes legacy sidebar connection and redis menu labels', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const connectionMenuStart = source.indexOf('// Connection Tag Menu — must be BEFORE the connection check'); const connectionMenuSource = source.slice( connectionMenuStart, @@ -2267,7 +2283,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes connection-root tab titles without changing database or redis-db tab title paths', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("const buildConnectionRootQueryTabTitle = () => t('query.new');"); expect(source).toContain("const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') =>"); @@ -2283,7 +2299,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes sidebar JVM probe and resource failure prompts', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("t('sidebar.message.jvm_provider_probe_failed_with_diagnostic'"); expect(source).toContain("t('sidebar.message.jvm_provider_probe_exception_with_diagnostic'"); @@ -2307,7 +2323,8 @@ describe('Sidebar locate toolbar', () => { }); it('localizes v2 saved-query and external SQL root shell copy', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); + const legacyMenuSource = readLegacyNodeMenuSource(); const loadTablesStart = source.indexOf('const loadTables = async (node: any) => {'); const loadTablesEnd = source.indexOf('const config = {', loadTablesStart); const loadTablesSource = source.slice(loadTablesStart, loadTablesEnd); @@ -2318,17 +2335,17 @@ describe('Sidebar locate toolbar', () => { const externalSqlFlowEnd = source.indexOf('const handleCreateDatabase = async () => {', externalSqlFlowStart); const externalSqlFlowSource = source.slice(externalSqlFlowStart, externalSqlFlowEnd); const treeTitleStart = source.indexOf('const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {'); - const treeTitleEnd = source.indexOf('const renderV2CommandSearchRow', treeTitleStart); + const treeTitleEnd = source.indexOf('const selectConnectionFromRail', treeTitleStart); const treeTitleSource = source.slice(treeTitleStart, treeTitleEnd); - const externalSqlMenuStart = source.indexOf("if (node.type === 'external-sql-root') {", source.indexOf('// 已存查询节点的右键菜单')); - const externalSqlMenuEnd = source.indexOf("if (node.type === 'external-sql-directory') {", externalSqlMenuStart); - const externalSqlMenuSource = source.slice(externalSqlMenuStart, externalSqlMenuEnd); + const externalSqlMenuStart = legacyMenuSource.indexOf("if (node.type === 'external-sql-root') {", legacyMenuSource.indexOf('// 已存查询节点的右键菜单')); + const externalSqlMenuEnd = legacyMenuSource.indexOf("if (node.type === 'external-sql-directory') {", externalSqlMenuStart); + const externalSqlMenuSource = legacyMenuSource.slice(externalSqlMenuStart, externalSqlMenuEnd); const externalSqlDirectoryMenuStart = externalSqlMenuEnd; - const externalSqlDirectoryMenuEnd = source.indexOf("if (node.type === 'external-sql-file') {", externalSqlDirectoryMenuStart); - const externalSqlDirectoryMenuSource = source.slice(externalSqlDirectoryMenuStart, externalSqlDirectoryMenuEnd); + const externalSqlDirectoryMenuEnd = legacyMenuSource.indexOf("if (node.type === 'external-sql-file') {", externalSqlDirectoryMenuStart); + const externalSqlDirectoryMenuSource = legacyMenuSource.slice(externalSqlDirectoryMenuStart, externalSqlDirectoryMenuEnd); const externalSqlFileMenuStart = externalSqlDirectoryMenuEnd; - const externalSqlFileMenuEnd = source.indexOf('return [];', externalSqlFileMenuStart); - const externalSqlFileMenuSource = source.slice(externalSqlFileMenuStart, externalSqlFileMenuEnd); + const externalSqlFileMenuEnd = legacyMenuSource.indexOf('return [];', externalSqlFileMenuStart); + const externalSqlFileMenuSource = legacyMenuSource.slice(externalSqlFileMenuStart, externalSqlFileMenuEnd); const titleRenderStart = source.indexOf('const titleRender = (node: any) => {'); const titleRenderEnd = source.indexOf('const handleDrop = (info: any) => {', titleRenderStart); const titleRenderSource = source.slice(titleRenderStart, titleRenderEnd); @@ -2558,7 +2575,7 @@ describe('Sidebar locate toolbar', () => { expect(markup).not.toContain(rawSnippet); }); - const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const sidebarSource = readSidebarSource(); const start = sidebarSource.indexOf('const renderV2TableGroupContextMenu'); const end = sidebarSource.indexOf('const renderV2DatabaseContextMenu', start); expect(start).toBeGreaterThanOrEqual(0); @@ -2571,18 +2588,19 @@ describe('Sidebar locate toolbar', () => { }); const treeTitleStart = sidebarSource.indexOf('const renderV2TreeTitle'); - const treeTitleEnd = sidebarSource.indexOf('const renderV2CommandSearchRow', treeTitleStart); + const treeTitleEnd = sidebarSource.indexOf('const selectConnectionFromRail', treeTitleStart); expect(treeTitleStart).toBeGreaterThanOrEqual(0); expect(treeTitleEnd).toBeGreaterThan(treeTitleStart); const treeTitleSource = sidebarSource.slice(treeTitleStart, treeTitleEnd); expect(treeTitleSource).toContain('const objectGroupTitle = resolveV2ObjectGroupTitle(node);'); expect(treeTitleSource).toContain('if (objectGroupTitle) return objectGroupTitle;'); - const objectGroupTitleStart = sidebarSource.indexOf('export const resolveV2ObjectGroupTitle'); - const objectGroupTitleEnd = sidebarSource.indexOf('export type SQLFileExecutionStatus', objectGroupTitleStart); + const sidebarHelpersSource = readSourceFile('./sidebar/sidebarHelpers.ts'); + const objectGroupTitleStart = sidebarHelpersSource.indexOf('export const resolveV2ObjectGroupTitle'); + const objectGroupTitleEnd = sidebarHelpersSource.indexOf('export type V2CommandSearchMode', objectGroupTitleStart); expect(objectGroupTitleStart).toBeGreaterThanOrEqual(0); expect(objectGroupTitleEnd).toBeGreaterThan(objectGroupTitleStart); - const objectGroupTitleSource = sidebarSource.slice(objectGroupTitleStart, objectGroupTitleEnd); + const objectGroupTitleSource = sidebarHelpersSource.slice(objectGroupTitleStart, objectGroupTitleEnd); [ "if (groupKey === 'tables') return t('sidebar.v2_table_group_menu.title');", "if (groupKey === 'views') return t('sidebar.object_group.views');", @@ -2616,7 +2634,7 @@ describe('Sidebar locate toolbar', () => { }); it('listens for table overview pin changes to refresh the matching sidebar database node', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("window.addEventListener('gonavi:sidebar-table-pin-changed'"); expect(source).toContain('findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`)'); @@ -2625,7 +2643,7 @@ describe('Sidebar locate toolbar', () => { }); it('waits long enough for slow object-tree loads before reporting locate misses', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain('const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;'); expect(source).toContain('const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;'); @@ -2636,7 +2654,7 @@ describe('Sidebar locate toolbar', () => { }); it('resolves sidebar export workbench connection ids from live tree nodes instead of only reading dataRef.connectionId', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();"); expect(source).not.toContain("const connectionId = String(node?.dataRef?.connectionId || '').trim();"); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 171e3a2d..ffe39b02 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import Modal from './common/ResizableDraggableModal'; import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; +import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -7427,1012 +7428,87 @@ const Sidebar: React.FC<{ } }; - const getNodeMenuItems = (node: any): MenuProps['items'] => { - const conn = node.dataRef as SavedConnection; - const isRedis = conn?.config?.type === 'redis'; - - if (node.type === 'object-group' && node.dataRef?.groupKey === 'schema') { - const dialect = getMetadataDialect(node.dataRef as SavedConnection); - const schemaName = String(node?.dataRef?.schemaName || '').trim(); - if (!isPostgresSchemaDialect(dialect) || !schemaName) { - return []; - } - return [ - { - key: 'rename-schema', - label: '编辑模式', - icon: , - onClick: () => openRenameSchemaModal(node) - }, - { - key: 'refresh-schema', - label: '刷新', - icon: , - onClick: () => void loadTables(getDatabaseNodeRef(node.dataRef, node.dataRef.dbName)) - }, - { - key: 'export-schema', - label: '导出当前模式表结构 (SQL)', - icon: , - onClick: () => void handleExportSchemaSQL(node, false) - }, - { - key: 'backup-schema-sql', - label: '备份当前模式全部表 (结构+数据 SQL)', - icon: , - onClick: () => void handleExportSchemaSQL(node, true) - }, - { type: 'divider' }, - { - key: 'drop-schema', - label: '删除模式', - icon: , - danger: true, - onClick: () => handleDeleteSchema(node) - }, - ]; - } - - // 表分组节点的右键菜单 - if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { - const groupData = node.dataRef; // { ...conn, dbName, groupKey } - const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; - const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; - const canCreateTable = !isStructureOnlyDbType(String(groupData.id || '')); - - return [ - ...(canCreateTable ? [{ - key: 'new-table', - label: '新建表', - icon: , - onClick: () => openNewTableDesign(node) - }] : []), - { type: 'divider' }, - { - key: 'sort-by-name', - label: '按名称排序', - icon: currentSort === 'name' ? : null, - onClick: () => handleTableGroupSortAction(node, 'name') - }, - { - key: 'sort-by-frequency', - label: '按使用频率排序', - icon: currentSort === 'frequency' ? : null, - onClick: () => handleTableGroupSortAction(node, 'frequency') - } - ]; - } - - // 视图分组节点的右键菜单 - if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') { - return [ - { - key: 'create-view', - label: t('sidebar.menu.create_view'), - icon: , - onClick: () => openCreateView(node) - }, - ]; - } - - if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') { - return [ - { - key: 'create-materialized-view', - label: t('sidebar.v2_database_menu.new_materialized_view'), - icon: , - onClick: () => openCreateStarRocksMaterializedView(node) - }, - ]; - } - - // 函数分组节点的右键菜单 - if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') { - const dialect = getMetadataDialect(node.dataRef as SavedConnection); - const routineMenu: MenuProps['items'] = [ - { - key: 'create-function', - label: t('sidebar.tab.create_function'), - icon: , - onClick: () => openCreateRoutine(node, 'FUNCTION') - }, - ]; - if (dialect !== 'duckdb') { - routineMenu.push({ - key: 'create-procedure', - label: t('sidebar.tab.create_procedure'), - icon: , - onClick: () => openCreateRoutine(node, 'PROCEDURE') - }); - } - return routineMenu; - } - - if (node.type === 'object-group' && node.dataRef?.groupKey === 'events') { - return [ - { - key: 'create-event-query', - label: '新建事件', - icon: , - onClick: () => { - addTab({ - id: `query-create-event-${Date.now()}`, - title: '新建事件', - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: `CREATE EVENT event_name\nON SCHEDULE EVERY 1 DAY\nDO\nBEGIN\n -- event body\nEND;` - }); - } - }, - ]; - } - - // Connection Tag Menu — must be BEFORE the connection check - if (node.type === 'tag') { - return [ - { - key: 'edit-tag', - label: '编辑标签', - icon: , - onClick: () => { - createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds }); - setRenameViewTarget(node); - setIsCreateTagModalOpen(true); - } - }, - { type: 'divider' }, - { - key: 'delete-tag', - label: '删除标签', - icon: , - danger: true, - onClick: () => { - Modal.confirm({ - title: '确认删除', - content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`, - onOk: () => { - removeConnectionTag(node.dataRef.id); - } - }); - } - } - ]; - } - - if (node.type === 'connection') { - // Redis connection menu - if (isRedis) { - return [ - { - key: 'refresh', - label: t('sidebar.menu.refresh'), - icon: , - onClick: () => { - const connKey = String(node.key); - // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - // 清除 loadingNodesRef 中残留的子节点加载标记 - Array.from(loadingNodesRef.current).forEach(lk => { - if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); - }); - loadDatabases(node); - } - }, - { type: 'divider' }, - { - key: 'new-command', - label: t('sidebar.menu.new_command_window'), - icon: , - onClick: () => { - addTab({ - id: `redis-cmd-${node.key}-${Date.now()}`, - title: buildConnectionRootRedisCommandTabTitle(), - type: 'redis-command', - connectionId: node.key, - redisDB: 0 - }); - } - }, - { - key: 'open-monitor', - label: t('redis_monitor.title.instance'), - icon: , - onClick: () => { - addTab({ - id: `redis-monitor-${node.key}-${Date.now()}`, - title: buildConnectionRootRedisMonitorTabTitle(), - type: 'redis-monitor', - connectionId: node.key, - redisDB: 0 - }); - } - }, - { type: 'divider' }, - { - key: 'edit', - label: t('sidebar.menu.edit_connection'), - icon: , - onClick: () => { - if (onEditConnection) onEditConnection(node.dataRef); - } - }, - { - key: 'copy-connection', - label: t('connection.sidebar.menu.copy'), - icon: , - onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) - }, - { - key: 'disconnect', - label: t('connection.sidebar.menu.disconnect'), - icon: , - onClick: () => void disconnectConnectionNode(node) - }, - { - key: 'delete', - label: t('connection.sidebar.menu.delete'), - icon: , - danger: true, - onClick: () => deleteConnectionNode(node) - } - ]; - } - - // Tag submenu for connection - const tagSubMenuItems: MenuProps['items'] = connectionTags.map(tag => ({ - key: `move-to-tag-${tag.id}`, - label: tag.name, - icon: , - onClick: () => moveConnectionToTag(node.key, tag.id) - })); - if (connectionTags.length > 0) { - tagSubMenuItems.push({ type: 'divider' }); - } - tagSubMenuItems.push({ - key: 'move-to-ungrouped', - label: t('connection.sidebar.menu.moveOutTag'), - onClick: () => moveConnectionToTag(node.key, null) - }); - - // Regular database connection menu - const connectionCapabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); - return [ - ...(connectionCapabilities.supportsCreateDatabase ? [{ - key: 'new-db', - label: t('connection.sidebar.menu.createDatabase'), - icon: , - onClick: () => { - setTargetConnection(node); - setIsCreateDbModalOpen(true); - } - }] : []), - { - key: 'refresh', - label: t('sidebar.menu.refresh'), - icon: , - onClick: () => { - const connKey = String(node.key); - // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - // 清除 loadingNodesRef 中残留的子节点加载标记 - Array.from(loadingNodesRef.current).forEach(lk => { - if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); - }); - loadDatabases(node); - } - }, - { type: 'divider' }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: buildConnectionRootQueryTabTitle(), - type: 'query', - connectionId: node.key, - dbName: undefined, - query: '' - }); - } - }, - { - key: 'open-sql-file', - label: t('sidebar.sql_file_exec.title'), - icon: , - onClick: () => handleRunSQLFile(node) - }, - { type: 'divider' }, - { - key: 'edit', - label: t('sidebar.menu.edit_connection'), - icon: , - onClick: () => { - if (onEditConnection) onEditConnection(node.dataRef); - } - }, - { - key: 'copy-connection', - label: t('connection.sidebar.menu.copy'), - icon: , - onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) - }, - { - key: 'move-to-tag', - label: t('connection.sidebar.menu.moveToTag'), - icon: , - children: tagSubMenuItems - }, - { - key: 'disconnect', - label: t('connection.sidebar.menu.disconnect'), - icon: , - onClick: () => void disconnectConnectionNode(node) - }, - { - key: 'delete', - label: t('connection.sidebar.menu.delete'), - icon: , - danger: true, - onClick: () => deleteConnectionNode(node) - } - ]; - } else if (node.type === 'redis-db') { - // Redis database menu - const { id, redisDB } = node.dataRef; - return [ - { - key: 'open-keys', - label: t('redis_viewer.title.key_explorer'), - icon: , - onClick: () => { - addTab({ - id: `redis-keys-${id}-db${redisDB}`, - title: `db${redisDB}`, - type: 'redis-keys', - connectionId: id, - redisDB: redisDB - }); - } - }, - { - key: 'new-command', - label: t('sidebar.menu.new_command_window'), - icon: , - onClick: () => { - addTab({ - id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`, - title: buildConnectionRootRedisCommandTabTitle(`db${redisDB}`), - type: 'redis-command', - connectionId: id, - redisDB: redisDB - }); - } - }, - { - key: 'open-monitor', - label: t('redis_monitor.title.instance'), - icon: , - onClick: () => { - addTab({ - id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`, - title: buildConnectionRootRedisMonitorTabTitle(`db${redisDB}`), - type: 'redis-monitor', - connectionId: id, - redisDB: redisDB - }); - } - } - ]; - } else if (node.type === 'database') { - const databaseConn = node.dataRef as SavedConnection; - const dialect = getMetadataDialect(databaseConn); - const capabilities = getDataSourceCapabilities(databaseConn?.config); - const isStarRocks = dialect === 'starrocks'; - const supportsSchemaActions = isPostgresSchemaDialect(dialect); - const canCreateTable = !isStructureOnlyDbType(String(databaseConn?.id || '')); - return [ - ...(canCreateTable ? [{ - key: 'new-table', - label: t('sidebar.menu.create_table'), - icon: , - onClick: () => openNewTableDesign(node) - }] : []), - ...(supportsSchemaActions ? [ - { - key: 'new-schema', - label: t('sidebar.v2_database_menu.new_schema'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema') - }, - ] : []), - ...(isStarRocks ? [ - { - key: 'new-materialized-view', - label: t('sidebar.v2_database_menu.new_materialized_view'), - icon: , - onClick: () => openCreateStarRocksMaterializedView(node) - }, - { - key: 'new-external-catalog', - label: t('sidebar.v2_database_menu.new_external_catalog'), - icon: , - onClick: () => openCreateStarRocksExternalCatalog(node) - }, - ] : []), - ...(capabilities.supportsRenameDatabase ? [{ - key: 'rename-db', - label: t('sidebar.menu.rename_database'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db') - }] : []), - ...(capabilities.supportsDropDatabase ? [{ - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - { - key: 'drop-db', - label: t('sidebar.v2_table_menu.item_with_suffix', { label: t('sidebar.menu.delete_database'), suffix: 'DROP' }), - icon: , - danger: true, - onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db') - } - ] - }] : []), - { - key: 'refresh', - label: t('sidebar.v2_database_menu.refresh_object_tree'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh') - }, - { - key: 'export-db-schema', - label: t('sidebar.v2_database_menu.export_all_table_schema_sql'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema') - }, - { - key: 'backup-db-sql', - label: t('sidebar.v2_database_menu.backup_all_tables_sql'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql') - }, - { type: 'divider' }, - { - key: 'disconnect-db', - label: t('sidebar.menu.close_database'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db') - }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query') - }, - { - key: 'run-sql', - label: t('sidebar.sql_file_exec.title'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql') - } - ]; - } else if (node.type === 'view') { - return [ - { - key: 'open-view', - label: t('sidebar.menu.browse_view_data'), - icon: , - onClick: () => onDoubleClick(null, node) - }, - { - key: 'view-definition', - label: t('sidebar.menu.view_definition'), - icon: , - onClick: () => openViewDefinition(node) - }, - { - key: 'copy-view-name', - label: '复制名称', - icon: , - onClick: () => handleCopyTableName(node) - }, - { type: 'divider' }, - { - key: 'edit-view', - label: t('sidebar.menu.edit_view'), - icon: , - onClick: () => openEditView(node) - }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: t('query.new'), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: '' - }); - } - }, - { type: 'divider' }, - { - key: 'rename-view', - label: t('sidebar.menu.rename_view'), - icon: , - onClick: () => { - setRenameViewTarget(node); - renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) }); - setIsRenameViewModalOpen(true); - } - }, - { - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - { - key: 'drop-view', - label: t('sidebar.menu.delete_view'), - icon: , - danger: true, - onClick: () => handleDropView(node) - } - ] - }, - ]; - } else if (node.type === 'materialized-view') { - return [ - { - key: 'open-materialized-view', - label: t('sidebar.menu.browse_materialized_view_data'), - icon: , - onClick: () => onDoubleClick(null, node) - }, - { - key: 'materialized-view-definition', - label: t('sidebar.menu.materialized_view_definition'), - icon: , - onClick: () => openViewDefinition(node) - }, - { - key: 'copy-materialized-view-name', - label: '复制名称', - icon: , - onClick: () => handleCopyTableName(node) - }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: t('query.new'), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || '')) - }); - } - }, - ]; - } else if (node.type === 'routine') { - const routineType = node.dataRef?.routineType || 'FUNCTION'; - const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); - return [ - { - key: 'view-routine-def', - label: t('sidebar.menu.view_object_definition'), - icon: , - onClick: () => openRoutineDefinition(node) - }, - { - key: 'edit-routine', - label: t('sidebar.menu.edit_definition'), - icon: , - onClick: () => openEditRoutine(node) - }, - { type: 'divider' }, - { - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - { - key: 'drop-routine', - label: t('sidebar.menu.delete_routine', { type: typeLabel }), - icon: , - danger: true, - onClick: () => handleDropRoutine(node) - } - ] - }, - ]; - } else if (node.type === 'db-event') { - return [ - { - key: 'view-event-def', - label: t('sidebar.menu.view_object_definition'), - icon: , - onClick: () => openEventDefinition(node) - }, - { - key: 'edit-event-query', - label: t('sidebar.menu.edit_definition'), - icon: , - onClick: () => { - const { eventName, dbName, id } = node.dataRef; - addTab({ - id: `query-edit-event-${Date.now()}`, - title: t('sidebar.tab.edit_event', { name: eventName }), - type: 'query', - connectionId: id, - dbName, - query: `SHOW CREATE EVENT \`${String(eventName || '').replace(/`/g, '``')}\`;` - }); - } - }, - ]; - } else if (node.type === 'table') { - const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; - const messagePublishTarget = resolveMessagePublishTarget(node); - return [ - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - 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 - }); - } - }, - ...(messagePublishTarget ? [{ - key: 'publish-message', - label: '测试发送消息', - icon: , - onClick: () => openMessagePublishModal(node), - }] : []), - { type: 'divider' }, - { - key: 'design-table', - label: isStructureOnlyDbType(String(node.dataRef?.id || '')) ? '表结构' : '设计表', - icon: , - onClick: () => openDesign(node, 'columns', false) - }, - ...(isStarRocks ? [{ - key: 'new-rollup', - label: '新增 Rollup', - icon: , - onClick: () => openCreateStarRocksRollup(node) - }] : []), - { - key: 'copy-table-name', - label: '复制表名', - icon: , - onClick: () => handleCopyTableName(node) - }, - { - key: 'copy-structure', - label: '复制表结构', - icon: , - onClick: () => handleCopyStructure(node) - }, - { - key: 'backup-table', - label: '备份表 (SQL)', - icon: , - onClick: () => handleExport(node, { format: 'sql' }) - }, - { - key: 'rename-table', - label: '重命名表', - icon: , - onClick: () => { - setRenameTableTarget(node); - renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); - setIsRenameTableModalOpen(true); - } - }, - { - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - ...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{ - key: 'truncate-table', - label: '截断表', - danger: true, - onClick: () => handleTableDataDangerAction(node, 'truncate') - }] : []), - { - key: 'clear-table', - label: '清空表', - danger: true, - onClick: () => handleTableDataDangerAction(node, 'clear') - }, - { - key: 'drop-table', - label: '删除表', - icon: , - danger: true, - onClick: () => handleDeleteTable(node) - } - ] - }, - { - type: 'divider' - }, - { - key: 'export', - label: '导出表数据…', - icon: , - onClick: () => openExportDialog(node), - } - ]; - } - - // 已存查询节点的右键菜单 - if (node.type === 'saved-query') { - const q = node.dataRef as SavedQuery; - const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q) - ? [ - { - key: 'rebind-query', - label: '绑定到连接', - icon: , - disabled: connections.length === 0, - children: connections.length > 0 - ? connections.map((conn) => ({ - key: `rebind-query-${conn.id}`, - label: conn.name || conn.id, - onClick: () => void handleRebindSavedQuery(q, conn), - })) - : undefined, - }, - ] - : []; - return [ - { - key: 'open-query', - label: t('sidebar.menu.open_query'), - icon: , - onClick: () => { - addTab({ - id: q.id, - title: resolveSavedQueryDisplayName(q.name), - type: 'query', - connectionId: q.connectionId, - dbName: q.dbName, - query: q.sql, - savedQueryId: q.id, - }); - } - }, - ...rebindMenuItems, - { type: 'divider' }, - { - key: 'rename-query', - label: t('sidebar.menu.rename_query'), - icon: , - onClick: () => openRenameSavedQueryModal(q), - }, - { - key: 'delete-query', - label: t('sidebar.menu.delete_query'), - icon: , - danger: true, - onClick: () => { - Modal.confirm({ - title: t('sidebar.modal.confirm_delete.title'), - content: t('sidebar.modal.confirm_delete_saved_query.content', { name: resolveSavedQueryDisplayName(q.name) }), - okButtonProps: { danger: true }, - onOk: async () => { - try { - await deleteQuery(q.id); - } catch (e) { - message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e))); - throw e; - } - // 从树中移除节点 - const removeNode = (list: TreeNode[]): TreeNode[] => - list - .filter(n => !(n.type === 'saved-query' && n.dataRef?.id === q.id)) - .map(n => n.children ? { ...n, children: removeNode(n.children) } : n); - const nextTreeData = removeNode(treeDataRef.current); - treeDataRef.current = nextTreeData; - setTreeData(nextTreeData); - message.success(t('sidebar.message.saved_query_deleted')); - } - }); - } - } - ]; - } - - if (node.type === 'external-sql-root') { - return [ - { - key: 'add-external-sql-directory', - label: t('sidebar.menu.add_sql_directory'), - icon: , - onClick: () => { - void handleAddExternalSQLDirectory(node); - } - } - ]; - } - - if (node.type === 'external-sql-directory') { - return [ - { - key: 'new-external-sql-file', - label: t('sidebar.menu.new_sql_file'), - icon: , - onClick: () => { - openCreateExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-directory', - label: t('sidebar.menu.new_sql_directory'), - icon: , - onClick: () => { - openCreateExternalSQLDirectoryModal(node); - } - }, - { - key: 'rename-external-sql-directory', - label: t('sidebar.menu.rename_sql_directory'), - icon: , - onClick: () => { - openRenameExternalSQLDirectoryModal(node); - } - }, - { type: 'divider' }, - { - key: 'refresh-external-sql-directory', - label: t('sidebar.menu.refresh_directory'), - icon: , - onClick: () => { - void handleRefreshExternalSQLDirectory(node); - } - }, - { type: 'divider' }, - { - key: 'remove-external-sql-directory', - label: t('sidebar.menu.remove_directory'), - icon: , - danger: true, - onClick: () => { - void handleRemoveExternalSQLDirectory(node); - } - }, - { - key: 'delete-external-sql-directory', - label: t('sidebar.menu.delete_local_directory'), - icon: , - danger: true, - onClick: () => { - handleDeleteExternalSQLDirectory(node); - } - } - ]; - } - - if (node.type === 'external-sql-folder') { - return [ - { - key: 'new-external-sql-file', - label: t('sidebar.menu.new_sql_file'), - icon: , - onClick: () => { - openCreateExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-directory', - label: t('sidebar.menu.new_sql_directory'), - icon: , - onClick: () => { - openCreateExternalSQLDirectoryModal(node); - } - }, - { - key: 'rename-external-sql-directory', - label: t('sidebar.menu.rename_sql_directory'), - icon: , - onClick: () => { - openRenameExternalSQLDirectoryModal(node); - } - }, - { - key: 'refresh-external-sql-directory', - label: t('sidebar.menu.refresh_directory'), - icon: , - onClick: () => { - void handleRefreshExternalSQLDirectory(node); - } - }, - { type: 'divider' }, - { - key: 'delete-external-sql-directory', - label: t('sidebar.menu.delete_sql_directory'), - icon: , - danger: true, - onClick: () => { - handleDeleteExternalSQLDirectory(node); - } - } - ]; - } - - if (node.type === 'external-sql-file') { - return [ - { - key: 'open-external-sql-file', - label: t('sidebar.menu.open_sql_file'), - icon: , - onClick: () => { - void openExternalSQLFile(node); - } - }, - { - key: 'rename-external-sql-file', - label: t('sidebar.menu.rename_sql_file'), - icon: , - onClick: () => { - openRenameExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-file-sibling', - label: t('sidebar.menu.new_sql_file_in_directory'), - icon: , - onClick: () => { - openCreateExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-directory-sibling', - label: t('sidebar.menu.new_sql_directory_in_directory'), - icon: , - onClick: () => { - openCreateExternalSQLDirectoryModal(node); - } - }, - { type: 'divider' }, - { - key: 'delete-external-sql-file', - label: t('sidebar.menu.delete_sql_file'), - icon: , - danger: true, - onClick: () => { - handleDeleteExternalSQLFile(node); - } - } - ]; - } - - return []; - }; + 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'; diff --git a/frontend/src/components/SidebarFilterSync.i18n.test.ts b/frontend/src/components/SidebarFilterSync.i18n.test.ts index 1ee101c8..77179312 100644 --- a/frontend/src/components/SidebarFilterSync.i18n.test.ts +++ b/frontend/src/components/SidebarFilterSync.i18n.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = [ + readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('./sidebar/SidebarSearchPanel.tsx', import.meta.url), 'utf8'), +].join('\n'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ diff --git a/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts b/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts index 061f762b..27c7922e 100644 --- a/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts +++ b/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = [ + readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8'), +].join('\n'); describe('Sidebar Redis DB menu i18n', () => { it('localizes Redis database context menu labels and tab titles', () => { diff --git a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx new file mode 100644 index 00000000..f6e8c163 --- /dev/null +++ b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx @@ -0,0 +1,1135 @@ +import { Modal, message, type MenuProps } from 'antd'; +import { + CheckSquareOutlined, + CloudOutlined, + CodeOutlined, + ConsoleSqlOutlined, + CopyOutlined, + DashboardOutlined, + DatabaseOutlined, + DeleteOutlined, + DisconnectOutlined, + EditOutlined, + ExportOutlined, + EyeOutlined, + FileAddOutlined, + FolderAddOutlined, + FolderOpenOutlined, + FolderOutlined, + KeyOutlined, + LinkOutlined, + PlusOutlined, + ReloadOutlined, + SaveOutlined, + SendOutlined, + TableOutlined, + ThunderboltOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import { t } from '../../i18n'; +import type { SavedConnection, SavedQuery } from '../../types'; +import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'; +import { buildTableSelectQuery } from '../../utils/objectQueryTemplates'; +import { supportsTableTruncateAction } from '../tableDataDangerActions'; + +type TreeNode = { + type?: string; + title?: string; + key?: string; + dataRef?: any; + children?: TreeNode[]; + [key: string]: any; +}; + +export type SidebarLegacyNodeMenuContext = Record; + +export const buildSidebarLegacyNodeMenuItems = ( + node: any, + context: SidebarLegacyNodeMenuContext, +): MenuProps['items'] => { + const { + 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, + } = context; + const conn = node.dataRef as SavedConnection; + const isRedis = conn?.config?.type === 'redis'; + + if (node.type === 'object-group' && node.dataRef?.groupKey === 'schema') { + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const schemaName = String(node?.dataRef?.schemaName || '').trim(); + if (!isPostgresSchemaDialect(dialect) || !schemaName) { + return []; + } + return [ + { + key: 'rename-schema', + label: '编辑模式', + icon: , + onClick: () => openRenameSchemaModal(node) + }, + { + key: 'refresh-schema', + label: '刷新', + icon: , + onClick: () => void loadTables(getDatabaseNodeRef(node.dataRef, node.dataRef.dbName)) + }, + { + key: 'export-schema', + label: '导出当前模式表结构 (SQL)', + icon: , + onClick: () => void handleExportSchemaSQL(node, false) + }, + { + key: 'backup-schema-sql', + label: '备份当前模式全部表 (结构+数据 SQL)', + icon: , + onClick: () => void handleExportSchemaSQL(node, true) + }, + { type: 'divider' }, + { + key: 'drop-schema', + label: '删除模式', + icon: , + danger: true, + onClick: () => handleDeleteSchema(node) + }, + ]; + } + + // 表分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { + const groupData = node.dataRef; // { ...conn, dbName, groupKey } + const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; + const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; + const canCreateTable = !isStructureOnlyDbType(String(groupData.id || '')); + + return [ + ...(canCreateTable ? [{ + key: 'new-table', + label: '新建表', + icon: , + onClick: () => openNewTableDesign(node) + }] : []), + { type: 'divider' }, + { + key: 'sort-by-name', + label: '按名称排序', + icon: currentSort === 'name' ? : null, + onClick: () => handleTableGroupSortAction(node, 'name') + }, + { + key: 'sort-by-frequency', + label: '按使用频率排序', + icon: currentSort === 'frequency' ? : null, + onClick: () => handleTableGroupSortAction(node, 'frequency') + } + ]; + } + + // 视图分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') { + return [ + { + key: 'create-view', + label: t('sidebar.menu.create_view'), + icon: , + onClick: () => openCreateView(node) + }, + ]; + } + + if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') { + return [ + { + key: 'create-materialized-view', + label: t('sidebar.v2_database_menu.new_materialized_view'), + icon: , + onClick: () => openCreateStarRocksMaterializedView(node) + }, + ]; + } + + // 函数分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') { + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const routineMenu: MenuProps['items'] = [ + { + key: 'create-function', + label: t('sidebar.tab.create_function'), + icon: , + onClick: () => openCreateRoutine(node, 'FUNCTION') + }, + ]; + if (dialect !== 'duckdb') { + routineMenu.push({ + key: 'create-procedure', + label: t('sidebar.tab.create_procedure'), + icon: , + onClick: () => openCreateRoutine(node, 'PROCEDURE') + }); + } + return routineMenu; + } + + if (node.type === 'object-group' && node.dataRef?.groupKey === 'events') { + return [ + { + key: 'create-event-query', + label: '新建事件', + icon: , + onClick: () => { + addTab({ + id: `query-create-event-${Date.now()}`, + title: '新建事件', + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: `CREATE EVENT event_name\nON SCHEDULE EVERY 1 DAY\nDO\nBEGIN\n -- event body\nEND;` + }); + } + }, + ]; + } + + // Connection Tag Menu — must be BEFORE the connection check + if (node.type === 'tag') { + return [ + { + key: 'edit-tag', + label: '编辑标签', + icon: , + onClick: () => { + createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds }); + setRenameViewTarget(node); + setIsCreateTagModalOpen(true); + } + }, + { type: 'divider' }, + { + key: 'delete-tag', + label: '删除标签', + icon: , + danger: true, + onClick: () => { + Modal.confirm({ + title: '确认删除', + content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`, + onOk: () => { + removeConnectionTag(node.dataRef.id); + } + }); + } + } + ]; + } + + if (node.type === 'connection') { + // Redis connection menu + if (isRedis) { + return [ + { + key: 'refresh', + label: t('sidebar.menu.refresh'), + icon: , + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current as Set).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } + }, + { type: 'divider' }, + { + key: 'new-command', + label: t('sidebar.menu.new_command_window'), + icon: , + onClick: () => { + addTab({ + id: `redis-cmd-${node.key}-${Date.now()}`, + title: buildConnectionRootRedisCommandTabTitle(), + type: 'redis-command', + connectionId: node.key, + redisDB: 0 + }); + } + }, + { + key: 'open-monitor', + label: t('redis_monitor.title.instance'), + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${node.key}-${Date.now()}`, + title: buildConnectionRootRedisMonitorTabTitle(), + type: 'redis-monitor', + connectionId: node.key, + redisDB: 0 + }); + } + }, + { type: 'divider' }, + { + key: 'edit', + label: t('sidebar.menu.edit_connection'), + icon: , + onClick: () => { + if (onEditConnection) onEditConnection(node.dataRef); + } + }, + { + key: 'copy-connection', + label: t('connection.sidebar.menu.copy'), + icon: , + onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) + }, + { + key: 'disconnect', + label: t('connection.sidebar.menu.disconnect'), + icon: , + onClick: () => void disconnectConnectionNode(node) + }, + { + key: 'delete', + label: t('connection.sidebar.menu.delete'), + icon: , + danger: true, + onClick: () => deleteConnectionNode(node) + } + ]; + } + + // Tag submenu for connection + const tagSubMenuItems: NonNullable = connectionTags.map((tag: any) => ({ + key: `move-to-tag-${tag.id}`, + label: tag.name, + icon: , + onClick: () => moveConnectionToTag(node.key, tag.id) + })); + if (connectionTags.length > 0) { + tagSubMenuItems.push({ type: 'divider' }); + } + tagSubMenuItems.push({ + key: 'move-to-ungrouped', + label: t('connection.sidebar.menu.moveOutTag'), + onClick: () => moveConnectionToTag(node.key, null) + }); + + // Regular database connection menu + const connectionCapabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); + return [ + ...(connectionCapabilities.supportsCreateDatabase ? [{ + key: 'new-db', + label: t('connection.sidebar.menu.createDatabase'), + icon: , + onClick: () => { + setTargetConnection(node); + setIsCreateDbModalOpen(true); + } + }] : []), + { + key: 'refresh', + label: t('sidebar.menu.refresh'), + icon: , + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current as Set).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } + }, + { type: 'divider' }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: buildConnectionRootQueryTabTitle(), + type: 'query', + connectionId: node.key, + dbName: undefined, + query: '' + }); + } + }, + { + key: 'open-sql-file', + label: t('sidebar.sql_file_exec.title'), + icon: , + onClick: () => handleRunSQLFile(node) + }, + { type: 'divider' }, + { + key: 'edit', + label: t('sidebar.menu.edit_connection'), + icon: , + onClick: () => { + if (onEditConnection) onEditConnection(node.dataRef); + } + }, + { + key: 'copy-connection', + label: t('connection.sidebar.menu.copy'), + icon: , + onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) + }, + { + key: 'move-to-tag', + label: t('connection.sidebar.menu.moveToTag'), + icon: , + children: tagSubMenuItems + }, + { + key: 'disconnect', + label: t('connection.sidebar.menu.disconnect'), + icon: , + onClick: () => void disconnectConnectionNode(node) + }, + { + key: 'delete', + label: t('connection.sidebar.menu.delete'), + icon: , + danger: true, + onClick: () => deleteConnectionNode(node) + } + ]; + } else if (node.type === 'redis-db') { + // Redis database menu + const { id, redisDB } = node.dataRef; + return [ + { + key: 'open-keys', + label: t('redis_viewer.title.key_explorer'), + icon: , + onClick: () => { + addTab({ + id: `redis-keys-${id}-db${redisDB}`, + title: `db${redisDB}`, + type: 'redis-keys', + connectionId: id, + redisDB: redisDB + }); + } + }, + { + key: 'new-command', + label: t('sidebar.menu.new_command_window'), + icon: , + onClick: () => { + addTab({ + id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`, + title: buildConnectionRootRedisCommandTabTitle(`db${redisDB}`), + type: 'redis-command', + connectionId: id, + redisDB: redisDB + }); + } + }, + { + key: 'open-monitor', + label: t('redis_monitor.title.instance'), + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`, + title: buildConnectionRootRedisMonitorTabTitle(`db${redisDB}`), + type: 'redis-monitor', + connectionId: id, + redisDB: redisDB + }); + } + } + ]; + } else if (node.type === 'database') { + const databaseConn = node.dataRef as SavedConnection; + const dialect = getMetadataDialect(databaseConn); + const capabilities = getDataSourceCapabilities(databaseConn?.config); + const isStarRocks = dialect === 'starrocks'; + const supportsSchemaActions = isPostgresSchemaDialect(dialect); + const canCreateTable = !isStructureOnlyDbType(String(databaseConn?.id || '')); + return [ + ...(canCreateTable ? [{ + key: 'new-table', + label: t('sidebar.menu.create_table'), + icon: , + onClick: () => openNewTableDesign(node) + }] : []), + ...(supportsSchemaActions ? [ + { + key: 'new-schema', + label: t('sidebar.v2_database_menu.new_schema'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema') + }, + ] : []), + ...(isStarRocks ? [ + { + key: 'new-materialized-view', + label: t('sidebar.v2_database_menu.new_materialized_view'), + icon: , + onClick: () => openCreateStarRocksMaterializedView(node) + }, + { + key: 'new-external-catalog', + label: t('sidebar.v2_database_menu.new_external_catalog'), + icon: , + onClick: () => openCreateStarRocksExternalCatalog(node) + }, + ] : []), + ...(capabilities.supportsRenameDatabase ? [{ + key: 'rename-db', + label: t('sidebar.menu.rename_database'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db') + }] : []), + ...(capabilities.supportsDropDatabase ? [{ + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + { + key: 'drop-db', + label: t('sidebar.v2_table_menu.item_with_suffix', { label: t('sidebar.menu.delete_database'), suffix: 'DROP' }), + icon: , + danger: true, + onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db') + } + ] + }] : []), + { + key: 'refresh', + label: t('sidebar.v2_database_menu.refresh_object_tree'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh') + }, + { + key: 'export-db-schema', + label: t('sidebar.v2_database_menu.export_all_table_schema_sql'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema') + }, + { + key: 'backup-db-sql', + label: t('sidebar.v2_database_menu.backup_all_tables_sql'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql') + }, + { type: 'divider' }, + { + key: 'disconnect-db', + label: t('sidebar.menu.close_database'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db') + }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query') + }, + { + key: 'run-sql', + label: t('sidebar.sql_file_exec.title'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql') + } + ]; + } else if (node.type === 'view') { + return [ + { + key: 'open-view', + label: t('sidebar.menu.browse_view_data'), + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'view-definition', + label: t('sidebar.menu.view_definition'), + icon: , + onClick: () => openViewDefinition(node) + }, + { + key: 'copy-view-name', + label: '复制名称', + icon: , + onClick: () => handleCopyTableName(node) + }, + { type: 'divider' }, + { + key: 'edit-view', + label: t('sidebar.menu.edit_view'), + icon: , + onClick: () => openEditView(node) + }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: t('query.new'), + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: '' + }); + } + }, + { type: 'divider' }, + { + key: 'rename-view', + label: t('sidebar.menu.rename_view'), + icon: , + onClick: () => { + setRenameViewTarget(node); + renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) }); + setIsRenameViewModalOpen(true); + } + }, + { + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + { + key: 'drop-view', + label: t('sidebar.menu.delete_view'), + icon: , + danger: true, + onClick: () => handleDropView(node) + } + ] + }, + ]; + } else if (node.type === 'materialized-view') { + return [ + { + key: 'open-materialized-view', + label: t('sidebar.menu.browse_materialized_view_data'), + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'materialized-view-definition', + label: t('sidebar.menu.materialized_view_definition'), + icon: , + onClick: () => openViewDefinition(node) + }, + { + key: 'copy-materialized-view-name', + label: '复制名称', + icon: , + onClick: () => handleCopyTableName(node) + }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: t('query.new'), + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || '')) + }); + } + }, + ]; + } else if (node.type === 'routine') { + const routineType = node.dataRef?.routineType || 'FUNCTION'; + const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); + return [ + { + key: 'view-routine-def', + label: t('sidebar.menu.view_object_definition'), + icon: , + onClick: () => openRoutineDefinition(node) + }, + { + key: 'edit-routine', + label: t('sidebar.menu.edit_definition'), + icon: , + onClick: () => openEditRoutine(node) + }, + { type: 'divider' }, + { + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + { + key: 'drop-routine', + label: t('sidebar.menu.delete_routine', { type: typeLabel }), + icon: , + danger: true, + onClick: () => handleDropRoutine(node) + } + ] + }, + ]; + } else if (node.type === 'db-event') { + return [ + { + key: 'view-event-def', + label: t('sidebar.menu.view_object_definition'), + icon: , + onClick: () => openEventDefinition(node) + }, + { + key: 'edit-event-query', + label: t('sidebar.menu.edit_definition'), + icon: , + onClick: () => { + const { eventName, dbName, id } = node.dataRef; + addTab({ + id: `query-edit-event-${Date.now()}`, + title: t('sidebar.tab.edit_event', { name: eventName }), + type: 'query', + connectionId: id, + dbName, + query: `SHOW CREATE EVENT \`${String(eventName || '').replace(/`/g, '``')}\`;` + }); + } + }, + ]; + } else if (node.type === 'table') { + const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; + const messagePublishTarget = resolveMessagePublishTarget(node); + return [ + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + 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 + }); + } + }, + ...(messagePublishTarget ? [{ + key: 'publish-message', + label: '测试发送消息', + icon: , + onClick: () => openMessagePublishModal(node), + }] : []), + { type: 'divider' }, + { + key: 'design-table', + label: isStructureOnlyDbType(String(node.dataRef?.id || '')) ? '表结构' : '设计表', + icon: , + onClick: () => openDesign(node, 'columns', false) + }, + ...(isStarRocks ? [{ + key: 'new-rollup', + label: '新增 Rollup', + icon: , + onClick: () => openCreateStarRocksRollup(node) + }] : []), + { + key: 'copy-table-name', + label: '复制表名', + icon: , + onClick: () => handleCopyTableName(node) + }, + { + key: 'copy-structure', + label: '复制表结构', + icon: , + onClick: () => handleCopyStructure(node) + }, + { + key: 'backup-table', + label: '备份表 (SQL)', + icon: , + onClick: () => handleExport(node, { format: 'sql' }) + }, + { + key: 'rename-table', + label: '重命名表', + icon: , + onClick: () => { + setRenameTableTarget(node); + renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); + setIsRenameTableModalOpen(true); + } + }, + { + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + ...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{ + key: 'truncate-table', + label: '截断表', + danger: true, + onClick: () => handleTableDataDangerAction(node, 'truncate') + }] : []), + { + key: 'clear-table', + label: '清空表', + danger: true, + onClick: () => handleTableDataDangerAction(node, 'clear') + }, + { + key: 'drop-table', + label: '删除表', + icon: , + danger: true, + onClick: () => handleDeleteTable(node) + } + ] + }, + { + type: 'divider' + }, + { + key: 'export', + label: '导出表数据…', + icon: , + onClick: () => openExportDialog(node), + } + ]; + } + + // 已存查询节点的右键菜单 + if (node.type === 'saved-query') { + const q = node.dataRef as SavedQuery; + const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q) + ? [ + { + key: 'rebind-query', + label: '绑定到连接', + icon: , + disabled: connections.length === 0, + children: connections.length > 0 + ? connections.map((conn: SavedConnection) => ({ + key: `rebind-query-${conn.id}`, + label: conn.name || conn.id, + onClick: () => void handleRebindSavedQuery(q, conn), + })) + : undefined, + }, + ] + : []; + return [ + { + key: 'open-query', + label: t('sidebar.menu.open_query'), + icon: , + onClick: () => { + addTab({ + id: q.id, + title: resolveSavedQueryDisplayName(q.name), + type: 'query', + connectionId: q.connectionId, + dbName: q.dbName, + query: q.sql, + savedQueryId: q.id, + }); + } + }, + ...rebindMenuItems, + { type: 'divider' }, + { + key: 'rename-query', + label: t('sidebar.menu.rename_query'), + icon: , + onClick: () => openRenameSavedQueryModal(q), + }, + { + key: 'delete-query', + label: t('sidebar.menu.delete_query'), + icon: , + danger: true, + onClick: () => { + Modal.confirm({ + title: t('sidebar.modal.confirm_delete.title'), + content: t('sidebar.modal.confirm_delete_saved_query.content', { name: resolveSavedQueryDisplayName(q.name) }), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteQuery(q.id); + } catch (e) { + message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e))); + throw e; + } + // 从树中移除节点 + const removeNode = (list: TreeNode[]): TreeNode[] => + list + .filter(n => !(n.type === 'saved-query' && n.dataRef?.id === q.id)) + .map(n => n.children ? { ...n, children: removeNode(n.children) } : n); + const nextTreeData = removeNode(treeDataRef.current); + treeDataRef.current = nextTreeData; + setTreeData(nextTreeData); + message.success(t('sidebar.message.saved_query_deleted')); + } + }); + } + } + ]; + } + + if (node.type === 'external-sql-root') { + return [ + { + key: 'add-external-sql-directory', + label: t('sidebar.menu.add_sql_directory'), + icon: , + onClick: () => { + void handleAddExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-directory') { + return [ + { + key: 'new-external-sql-file', + label: t('sidebar.menu.new_sql_file'), + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory', + label: t('sidebar.menu.new_sql_directory'), + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { + key: 'rename-external-sql-directory', + label: t('sidebar.menu.rename_sql_directory'), + icon: , + onClick: () => { + openRenameExternalSQLDirectoryModal(node); + } + }, + { type: 'divider' }, + { + key: 'refresh-external-sql-directory', + label: t('sidebar.menu.refresh_directory'), + icon: , + onClick: () => { + void handleRefreshExternalSQLDirectory(node); + } + }, + { type: 'divider' }, + { + key: 'remove-external-sql-directory', + label: t('sidebar.menu.remove_directory'), + icon: , + danger: true, + onClick: () => { + void handleRemoveExternalSQLDirectory(node); + } + }, + { + key: 'delete-external-sql-directory', + label: t('sidebar.menu.delete_local_directory'), + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-folder') { + return [ + { + key: 'new-external-sql-file', + label: t('sidebar.menu.new_sql_file'), + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory', + label: t('sidebar.menu.new_sql_directory'), + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { + key: 'rename-external-sql-directory', + label: t('sidebar.menu.rename_sql_directory'), + icon: , + onClick: () => { + openRenameExternalSQLDirectoryModal(node); + } + }, + { + key: 'refresh-external-sql-directory', + label: t('sidebar.menu.refresh_directory'), + icon: , + onClick: () => { + void handleRefreshExternalSQLDirectory(node); + } + }, + { type: 'divider' }, + { + key: 'delete-external-sql-directory', + label: t('sidebar.menu.delete_sql_directory'), + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-file') { + return [ + { + key: 'open-external-sql-file', + label: t('sidebar.menu.open_sql_file'), + icon: , + onClick: () => { + void openExternalSQLFile(node); + } + }, + { + key: 'rename-external-sql-file', + label: t('sidebar.menu.rename_sql_file'), + icon: , + onClick: () => { + openRenameExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-file-sibling', + label: t('sidebar.menu.new_sql_file_in_directory'), + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory-sibling', + label: t('sidebar.menu.new_sql_directory_in_directory'), + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { type: 'divider' }, + { + key: 'delete-external-sql-file', + label: t('sidebar.menu.delete_sql_file'), + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLFile(node); + } + } + ]; + } + + return []; + };