diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 249d60e..950dc0c 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -115,11 +115,13 @@ vi.mock('../store', () => ({ connections: mocks.state.connections, savedQueries: [], externalSQLDirectories: [], + saveQuery: mocks.noop, deleteQuery: mocks.noop, saveExternalSQLDirectory: mocks.noop, deleteExternalSQLDirectory: mocks.noop, addConnection: mocks.noop, addTab: mocks.noop, + updateQueryTabDraft: mocks.noop, tabs: mocks.state.tabs, activeTabId: mocks.state.activeTabId, setActiveContext: mocks.noop, @@ -145,8 +147,10 @@ vi.mock('../store', () => ({ setTableSortPreference: mocks.noop, setSidebarTablePinned: mocks.noop, addSqlLog: mocks.noop, + sqlLogs: [], shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS), setAIPanelVisible: mocks.noop, + addAIContext: mocks.noop, }), })); @@ -384,7 +388,7 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('gn-v2-explorer-command-trigger'); expect(markup).toContain('搜索表、连接、动作... 或问 AI'); expect(markup).toContain('gn-v2-search-shortcut'); - expect(markup).toContain(''); + expect(markup).toContain('Ctrl'); expect(markup).toContain('K'); expect(markup).toContain('gn-v2-explorer-filter-tabs'); expect(markup).toContain('全部'); @@ -414,7 +418,18 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("kind: 'v2-connection-group'"); expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}'); expect(source).toContain("kind: 'v2-connection'"); - expect(source).toContain("if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node);"); + expect(source).toContain('resolveSidebarContextMenuPosition(event.clientX, event.clientY)'); + expect(source).toContain('contextMenuPortalRef'); + expect(source).toContain('createPortal('); + expect(source).toContain('gn-v2-sidebar-context-menu-portal'); + expect(source).toContain('getBoundingClientRect()'); + expect(source).toContain("querySelector('.gn-v2-table-context-menu')"); + expect(source).toContain('content?.scrollHeight'); + expect(source).toContain("if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node);"); + expect(source).toContain('sourceX: event.clientX'); + expect(source).toContain("['--gn-v2-context-menu-max-height' as any]"); + expect(source).toContain('{contextMenu && !contextMenu.kind && ('); + expect(source).not.toContain("document.addEventListener('contextmenu', onPointerDown)"); const contextMenuFunction = source.slice( source.indexOf('const openV2ConnectionContextMenu = ('), source.indexOf('const getV2TreeMetaText = (node: any): string => {'), @@ -816,6 +831,16 @@ describe('Sidebar locate toolbar', () => { expect(filterV2ExplorerTreeByKind(tree, 'events')[0].children?.map((node) => node.key)).toEqual(['conn-main-events']); }); + it('adds rename to the saved query context menu', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('const openRenameSavedQueryModal = (query: SavedQuery) =>'); + expect(source).toContain("key: 'rename-query'"); + expect(source).toContain("label: '重命名查询'"); + expect(source).toContain('onClick: () => openRenameSavedQueryModal(q)'); + expect(source).toContain('const handleRenameSavedQuery = async () =>'); + }); + it('renders the v2 table context menu with the redesigned table layout', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('置顶表'); expect(markup).toContain('字段 / 索引 / 外键'); expect(markup).toContain('在新标签打开'); - expect(markup).toContain('⌘↵'); + expect(markup).toContain('Ctrl+Enter'); expect(markup).toContain('元信息'); expect(markup).toContain('查看 DDL · CREATE TABLE'); expect(markup).toContain('在 ER 图中查看'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3f04e69..651f2b2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; +import { createPortal } from 'react-dom'; import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd'; import { DatabaseOutlined, @@ -52,7 +53,7 @@ import { useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; - import { SavedConnection, ConnectionTag, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; + import { SavedConnection, SavedQuery, ConnectionTag, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; @@ -79,7 +80,7 @@ import { resolveConnectionAccentColor, resolveConnectionIconType } from '../util import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; -import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; +import { getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, resolveShortcutDisplay } from '../utils/shortcuts'; import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; import JVMModeBadge from './jvm/JVMModeBadge'; import { @@ -100,11 +101,45 @@ const { Search } = Input; type SidebarContextMenuState = { x: number; y: number; + sourceX?: number; + sourceY?: number; items: MenuProps['items']; kind?: 'v2-table' | 'v2-database' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group'; node?: any; rootClassName?: string; overlayStyle?: React.CSSProperties; + maxHeight?: number; +}; + +const SIDEBAR_CONTEXT_MENU_SAFE_GAP = 8; +const SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH = 264; +const SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT = 420; + +export const resolveSidebarContextMenuPosition = ( + x: number, + y: number, + options?: { + width?: number; + height?: number; + viewportWidth?: number; + viewportHeight?: number; + safeGap?: number; + }, +): { x: number; y: number; maxHeight: number } => { + const safeGap = options?.safeGap ?? SIDEBAR_CONTEXT_MENU_SAFE_GAP; + const viewportWidth = options?.viewportWidth ?? (typeof window === 'undefined' ? 1024 : window.innerWidth); + const viewportHeight = options?.viewportHeight ?? (typeof window === 'undefined' ? 768 : window.innerHeight); + const width = Math.max(0, options?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH); + const height = Math.max(0, options?.height ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT); + const maxX = Math.max(safeGap, viewportWidth - width - safeGap); + const maxY = Math.max(safeGap, viewportHeight - height - safeGap); + const nextX = Math.max(safeGap, Math.min(x, maxX)); + const nextY = Math.max(safeGap, Math.min(y, maxY)); + return { + x: nextX, + y: nextY, + maxHeight: Math.max(120, viewportHeight - nextY - safeGap), + }; }; interface TreeNode { title: string; @@ -837,11 +872,13 @@ const Sidebar: React.FC<{ 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); @@ -877,6 +914,7 @@ const Sidebar: React.FC<{ const disableLocalBackdropFilter = isMacLikePlatform(); const autoFetchVisible = useAutoFetchVisibility(); const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); + const primaryShortcutModifierLabel = getShortcutPrimaryModifierDisplayLabel(activeShortcutPlatform); const [treeData, setTreeData] = useState([]); const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]); const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]); @@ -963,6 +1001,7 @@ const Sidebar: React.FC<{ const driverUpdateWarningKeysRef = useRef>(new Set()); const connectionReloadSignaturesRef = useRef>({}); const [contextMenu, setContextMenu] = useState(null); + const contextMenuPortalRef = useRef(null); const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); const v2RailConnectionGroups = useMemo( @@ -1075,6 +1114,9 @@ const Sidebar: React.FC<{ const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false); const [renameViewForm] = Form.useForm(); const [renameViewTarget, setRenameViewTarget] = useState(null); + const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false); + const [renameSavedQueryForm] = Form.useForm(); + const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState(null); // Connection Tag Modals const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); @@ -4581,6 +4623,56 @@ const Sidebar: React.FC<{ } }; + const openRenameSavedQueryModal = (query: SavedQuery) => { + setRenameSavedQueryTarget(query); + renameSavedQueryForm.setFieldsValue({ name: query.name || '未命名查询' }); + setIsRenameSavedQueryModalOpen(true); + }; + + const handleRenameSavedQuery = async () => { + if (!renameSavedQueryTarget) return; + try { + const values = await renameSavedQueryForm.validateFields(); + const nextName = String(values.name || '').trim(); + if (!nextName) { + message.error('查询名称不能为空'); + return; + } + if (nextName === renameSavedQueryTarget.name) { + message.warning('新旧查询名称相同,无需修改'); + return; + } + + saveQuery({ + ...renameSavedQueryTarget, + name: nextName, + }); + const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] => + list.map(node => { + if (node.key === renameSavedQueryTarget.id) { + return { + ...node, + title: nextName, + dataRef: { ...(node.dataRef || renameSavedQueryTarget), name: nextName }, + }; + } + 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: nextName })); + message.success('查询已重命名'); + setIsRenameSavedQueryModalOpen(false); + setRenameSavedQueryTarget(null); + renameSavedQueryForm.resetFields(); + } catch (e) { + // Validate failed + } + }; + // --- 函数/存储过程操作 --- const openRoutineDefinition = (node: any) => { const { routineName, routineType, dbName, id } = node.dataRef; @@ -5798,14 +5890,18 @@ const Sidebar: React.FC<{ ? connOrNode as TreeNode : getConnectionNodeForAction(connOrNode as SavedConnection); if (!node?.key || !node?.dataRef) return; + const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); setContextMenu({ - x: event.clientX, - y: event.clientY, + x: position.x, + y: position.y, + sourceX: event.clientX, + sourceY: event.clientY, items: [], kind: 'v2-connection', node, rootClassName: 'gn-v2-table-context-menu-popup', - overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' } + overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, + maxHeight: position.maxHeight, }); }; @@ -5933,6 +6029,7 @@ const Sidebar: React.FC<{ return ( ); + const renderV2SidebarContextMenuContent = (menu: SidebarContextMenuState) => { + if (!menu.node) return null; + if (menu.kind === 'v2-table') return renderV2TableContextMenu(menu.node); + if (menu.kind === 'v2-database') return renderV2DatabaseContextMenu(menu.node); + if (menu.kind === 'v2-table-group') return renderV2TableGroupContextMenu(menu.node); + if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node); + if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node); + return null; + }; + + useEffect(() => { + if (!contextMenu?.kind) return; + const onPointerDown = (event: MouseEvent) => { + const target = event.target instanceof Node ? event.target : null; + if (target && contextMenuPortalRef.current?.contains(target)) return; + setContextMenu(null); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setContextMenu(null); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [contextMenu?.kind]); + + useEffect(() => { + if (!contextMenu?.kind) return; + const frame = requestAnimationFrame(() => { + const portal = contextMenuPortalRef.current; + if (!portal) return; + const rect = portal.getBoundingClientRect(); + const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; + const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); + const position = resolveSidebarContextMenuPosition(contextMenu.sourceX ?? contextMenu.x, contextMenu.sourceY ?? contextMenu.y, { + width: rect.width || SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, + height: measuredHeight || SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, + }); + setContextMenu(prev => { + if (!prev?.kind) return prev; + if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; + return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; + }); + }); + return () => cancelAnimationFrame(frame); + }, [contextMenu?.kind, contextMenu?.x, contextMenu?.y]); + const fetchV2TableContextMenuStats = async (node: any) => { const statsKey = getV2TableContextMenuStatsKey(node); if (!statsKey || v2TableContextMenuStats[statsKey]?.loading) return; @@ -7169,6 +7318,12 @@ const Sidebar: React.FC<{ } }, { type: 'divider' }, + { + key: 'rename-query', + label: '重命名查询', + icon: , + onClick: () => openRenameSavedQueryModal(q), + }, { key: 'delete-query', label: '删除查询', @@ -7482,38 +7637,50 @@ const Sidebar: React.FC<{ return; } if (isV2Ui && node?.type === 'database') { + const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); setContextMenu({ - x: event.clientX, - y: event.clientY, + 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)' } + 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: event.clientX, - y: event.clientY, + 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)' } + 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: event.clientX, - y: event.clientY, + 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)' } + overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, + maxHeight: position.maxHeight, }); return; } @@ -7650,14 +7817,18 @@ const Sidebar: React.FC<{ if (group.isUngrouped) return; event.preventDefault(); event.stopPropagation(); + const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); setContextMenu({ - x: event.clientX, - y: event.clientY, + x: position.x, + y: position.y, + sourceX: event.clientX, + sourceY: event.clientY, items: [], kind: 'v2-connection-group', node: group, rootClassName: 'gn-v2-table-context-menu-popup', overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, + maxHeight: position.maxHeight, }); }} aria-label={`${collapsed ? '展开' : '折叠'}连接分组 ${groupTitle}`} @@ -7833,7 +8004,7 @@ const Sidebar: React.FC<{ 搜索表、连接、动作... 或问 AI @@ -8049,21 +8220,34 @@ const Sidebar: React.FC<{ {renderV2CommandSearchOverlay()} - {contextMenu && ( + {contextMenu?.kind && typeof document !== 'undefined' && createPortal( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > + {renderV2SidebarContextMenuContent(contextMenu)} +
, + document.body, + )} + + {contextMenu && !contextMenu.kind && ( { if (!open) setContextMenu(null); }} trigger={['contextMenu']} - popupRender={(() => { - if (!contextMenu.node) return undefined; - if (contextMenu.kind === 'v2-table') return () => renderV2TableContextMenu(contextMenu.node); - if (contextMenu.kind === 'v2-database') return () => renderV2DatabaseContextMenu(contextMenu.node); - if (contextMenu.kind === 'v2-table-group') return () => renderV2TableGroupContextMenu(contextMenu.node); - if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node); - if (contextMenu.kind === 'v2-connection-group') return () => renderV2ConnectionGroupContextMenu(contextMenu.node); - return undefined; - })()} rootClassName={contextMenu.rootClassName} overlayStyle={contextMenu.overlayStyle} > @@ -8217,6 +8401,25 @@ const Sidebar: React.FC<{ + { + setIsRenameSavedQueryModalOpen(false); + setRenameSavedQueryTarget(null); + renameSavedQueryForm.resetFields(); + }} + okText="重命名" + cancelText="取消" + > +
+ + + +
+
+ , "批量操作表", "按对象批量导出结构、数据或完整备份。")} open={isBatchModalOpen} diff --git a/frontend/src/components/TableOverview.context-menu.test.ts b/frontend/src/components/TableOverview.context-menu.test.ts new file mode 100644 index 0000000..bec24d0 --- /dev/null +++ b/frontend/src/components/TableOverview.context-menu.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('TableOverview v2 context menu', () => { + it('renders card and list table context menus through a measured portal', () => { + const source = readFileSync(new URL('./TableOverview.tsx', import.meta.url), 'utf8'); + const cardSource = source.slice( + source.indexOf('const renderCardTableContent = (t: TableStatRow) => ('), + source.indexOf('const renderListTable = (t: TableStatRow) => {'), + ); + const listSource = source.slice( + source.indexOf('const renderListTable = (t: TableStatRow) => {'), + source.indexOf('if (loading) {'), + ); + + expect(source).toContain("import { createPortal } from 'react-dom';"); + expect(source).toContain('resolveOverviewContextMenuPosition(event.clientX, event.clientY)'); + expect(source).toContain('v2ContextMenuPortalRef'); + expect(source).toContain('content?.scrollHeight'); + expect(source).toContain('gn-v2-table-overview-context-menu-portal'); + expect(source).toContain("['--gn-v2-context-menu-max-height' as any]"); + expect(source).toContain('renderV2OverviewTableContextMenu(v2ContextMenuTable)'); + expect(cardSource).toContain('onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined}'); + expect(listSource).toContain('onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined}'); + expect(cardSource).not.toContain('popupRender'); + expect(listSource).not.toContain('popupRender'); + }); +}); diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index e238f1d..c9ddb3e 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd'; import type { MenuProps } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons'; @@ -20,6 +21,8 @@ import { type TableOverviewSortOrder, } from '../utils/tableOverviewFilter'; import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; +import { isMacLikePlatform } from '../utils/appearance'; +import { getShortcutPlatform } from '../utils/shortcuts'; import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu'; interface TableOverviewProps { @@ -40,6 +43,14 @@ interface TableStatRow { type SortField = TableOverviewSortField; type SortOrder = TableOverviewSortOrder; type ViewMode = 'card' | 'list'; +type OverviewContextMenuState = { + tableName: string; + x: number; + y: number; + sourceX: number; + sourceY: number; + maxHeight: number; +}; type OverviewTableSection = { key: string; title: string; @@ -47,6 +58,37 @@ type OverviewTableSection = { rows: TableStatRow[]; }; +const OVERVIEW_CONTEXT_MENU_SAFE_GAP = 8; +const OVERVIEW_CONTEXT_MENU_WIDTH = 264; +const OVERVIEW_CONTEXT_MENU_FALLBACK_HEIGHT = 420; + +const resolveOverviewContextMenuPosition = ( + x: number, + y: number, + options?: { + width?: number; + height?: number; + viewportWidth?: number; + viewportHeight?: number; + safeGap?: number; + }, +): { x: number; y: number; maxHeight: number } => { + const safeGap = options?.safeGap ?? OVERVIEW_CONTEXT_MENU_SAFE_GAP; + const viewportWidth = options?.viewportWidth ?? (typeof window === 'undefined' ? 1024 : window.innerWidth); + const viewportHeight = options?.viewportHeight ?? (typeof window === 'undefined' ? 768 : window.innerHeight); + const width = Math.max(0, options?.width ?? OVERVIEW_CONTEXT_MENU_WIDTH); + const height = Math.max(0, options?.height ?? OVERVIEW_CONTEXT_MENU_FALLBACK_HEIGHT); + const maxX = Math.max(safeGap, viewportWidth - width - safeGap); + const maxY = Math.max(safeGap, viewportHeight - height - safeGap); + const nextX = Math.max(safeGap, Math.min(x, maxX)); + const nextY = Math.max(safeGap, Math.min(y, maxY)); + return { + x: nextX, + y: nextY, + maxHeight: Math.max(120, viewportHeight - nextY - safeGap), + }; +}; + const formatSize = (bytes: number): string => { if (!bytes || bytes <= 0) return '—'; if (bytes < 1024) return `${bytes} B`; @@ -199,6 +241,7 @@ const TableOverview: React.FC = ({ tab }) => { const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned); const darkMode = theme === 'dark'; const isV2Ui = appearance.uiVersion === 'v2'; + const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); const [tables, setTables] = useState([]); const [loading, setLoading] = useState(true); @@ -206,7 +249,8 @@ const TableOverview: React.FC = ({ tab }) => { const [sortField, setSortField] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); const [viewMode, setViewMode] = useState(isV2Ui ? 'card' : 'list'); - const [openContextMenuTable, setOpenContextMenuTable] = useState(null); + const [v2ContextMenu, setV2ContextMenu] = useState(null); + const v2ContextMenuPortalRef = useRef(null); const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE); const deferredSearchText = useDeferredValue(searchText); const isSearchPending = searchText !== deferredSearchText; @@ -292,6 +336,66 @@ const TableOverview: React.FC = ({ tab }) => { ]; }, [connection?.id, pinnedOverview.pinnedRows, pinnedSidebarTables, schemaName, tab.dbName, visibleTables]); + const v2ContextMenuTable = useMemo( + () => (v2ContextMenu ? tables.find(table => table.name === v2ContextMenu.tableName) || null : null), + [tables, v2ContextMenu], + ); + + const openV2OverviewContextMenu = useCallback((event: React.MouseEvent, table: TableStatRow) => { + if (!isV2Ui) return; + event.preventDefault(); + event.stopPropagation(); + const position = resolveOverviewContextMenuPosition(event.clientX, event.clientY); + setV2ContextMenu({ + tableName: table.name, + x: position.x, + y: position.y, + sourceX: event.clientX, + sourceY: event.clientY, + maxHeight: position.maxHeight, + }); + }, [isV2Ui]); + + useEffect(() => { + if (!v2ContextMenu) return; + const onPointerDown = (event: MouseEvent) => { + const target = event.target instanceof Node ? event.target : null; + if (target && v2ContextMenuPortalRef.current?.contains(target)) return; + setV2ContextMenu(null); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + setV2ContextMenu(null); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [v2ContextMenu]); + + useEffect(() => { + if (!v2ContextMenu) return; + const frame = requestAnimationFrame(() => { + const portal = v2ContextMenuPortalRef.current; + if (!portal) return; + const rect = portal.getBoundingClientRect(); + const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; + const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); + const position = resolveOverviewContextMenuPosition(v2ContextMenu.sourceX, v2ContextMenu.sourceY, { + width: rect.width || OVERVIEW_CONTEXT_MENU_WIDTH, + height: measuredHeight || OVERVIEW_CONTEXT_MENU_FALLBACK_HEIGHT, + }); + setV2ContextMenu(prev => { + if (!prev) return prev; + if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; + return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; + }); + }); + return () => cancelAnimationFrame(frame); + }, [v2ContextMenu]); + const openTable = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); @@ -700,6 +804,7 @@ const TableOverview: React.FC = ({ tab }) => { const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => ( = ({ tab }) => { supportsTruncate={allowTruncate} supportsStarRocksRollup={metadataDialect === 'starrocks'} onAction={(action) => { - setOpenContextMenuTable(null); + setV2ContextMenu(null); handleV2TableContextMenuAction(table, action); }} /> - ), [allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, tab.dbName]); + ), [activeShortcutPlatform, allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, tab.dbName]); const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [ { key: 'new-query', label: '新建查询', icon: , onClick: () => openQueryForTable(table.name) }, @@ -768,61 +873,66 @@ const TableOverview: React.FC = ({ tab }) => { ); - const renderCardTable = (t: TableStatRow) => ( - setOpenContextMenuTable(open ? t.name : null) : undefined} - popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined} - rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined} - overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined} + const renderCardTableContent = (t: TableStatRow) => ( +
openTable(t.name)} + onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined} + style={{ + background: cardBg, + border: `1px solid ${cardBorder}`, + borderRadius: 10, + padding: '14px 16px', + cursor: 'pointer', + transition: isV2Ui ? undefined : 'all 0.15s ease', + userSelect: 'none', + }} + onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }} + onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }} > -
openTable(t.name)} - style={{ - background: cardBg, - border: `1px solid ${cardBorder}`, - borderRadius: 10, - padding: '14px 16px', - cursor: 'pointer', - transition: isV2Ui ? undefined : 'all 0.15s ease', - userSelect: 'none', - }} - onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }} - onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }} - > -
- - - - {t.name} - - -
- {t.comment && ( - -
- {t.comment} -
-
- )} -
- 📊 {formatRows(t.rows)} - 💾 {formatSize(t.dataSize)} - {t.engine && {t.engine}} -
- {isV2Ui && ( -
- 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} /> -
- )} +
+ + + + {t.name} + +
- + {t.comment && ( + +
+ {t.comment} +
+
+ )} +
+ 📊 {formatRows(t.rows)} + 💾 {formatSize(t.dataSize)} + {t.engine && {t.engine}} +
+ {isV2Ui && ( +
+ 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} /> +
+ )} +
); + const renderCardTable = (t: TableStatRow) => { + if (isV2Ui) { + return {renderCardTableContent(t)}; + } + return ( + + {renderCardTableContent(t)} + + ); + }; + const renderListTable = (t: TableStatRow) => { const combinedSize = t.dataSize + t.indexSize; const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0; @@ -830,20 +940,11 @@ const TableOverview: React.FC = ({ tab }) => { const fillColor = darkMode ? 'rgba(22,119,255,0.18)' : 'rgba(22,119,255,0.12)'; const rowSecondary = t.comment || (t.engine ? `${t.engine} 表` : '双击打开数据,右键查看更多操作'); - return ( - setOpenContextMenuTable(open ? t.name : null) : undefined} - popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined} - rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined} - overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined} - > + const content = (
openTable(t.name)} + onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined} style={{ position: 'relative', overflow: 'hidden', @@ -931,6 +1032,19 @@ const TableOverview: React.FC = ({ tab }) => {
+ ); + + if (isV2Ui) { + return {content}; + } + + return ( + + {content} ); }; @@ -1061,6 +1175,27 @@ const TableOverview: React.FC = ({ tab }) => { )} + {isV2Ui && v2ContextMenu && v2ContextMenuTable && typeof document !== 'undefined' && createPortal( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > + {renderV2OverviewTableContextMenu(v2ContextMenuTable)} +
, + document.body, + )} ); }; diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index 5f8465d..95d7113 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -30,6 +30,7 @@ import { SortDescendingOutlined, VerticalAlignBottomOutlined, } from '@ant-design/icons'; +import { getPrimaryShortcutDisplayLabel, type ShortcutPlatform } from '../utils/shortcuts'; export type V2TableContextMenuActionKey = | 'pin-table' @@ -153,6 +154,7 @@ const renderV2ContextMenuItems = ( export const V2TableContextMenuView: React.FC<{ tableName: string; + shortcutPlatform?: ShortcutPlatform; stats?: V2TableContextMenuStats; isPinned?: boolean; supportsTruncate?: boolean; @@ -160,6 +162,7 @@ export const V2TableContextMenuView: React.FC<{ onAction?: (action: V2TableContextMenuActionKey) => void; }> = ({ tableName, + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, stats, isPinned = false, supportsTruncate = true, @@ -196,8 +199,8 @@ export const V2TableContextMenuView: React.FC<{ {renderItems([ { action: 'open-data', icon: , title: '查看数据', kbd: '↵', featured: true }, { action: isPinned ? 'unpin-table' : 'pin-table', icon: , title: isPinned ? '取消置顶' : '置顶表', kbd: isPinned ? '已置顶' : undefined, selected: isPinned }, - { action: 'design-table', icon: , title: '设计表 · 字段 / 索引 / 外键', kbd: '⌘D' }, - { action: 'open-new-tab', icon: , title: '在新标签打开', kbd: '⌘↵' }, + { action: 'design-table', icon: , title: '设计表 · 字段 / 索引 / 外键', kbd: primaryShortcut('D', shortcutPlatform) }, + { action: 'open-new-tab', icon: , title: '在新标签打开', kbd: primaryShortcut('Enter', shortcutPlatform) }, { action: 'new-query', icon: , title: '新建查询' }, ])} @@ -209,7 +212,7 @@ export const V2TableContextMenuView: React.FC<{
复制
{renderItems([ - { action: 'copy-table-name', icon: , title: '复制表名', kbd: '⌘C' }, + { action: 'copy-table-name', icon: , title: '复制表名', kbd: primaryShortcut('C', shortcutPlatform) }, { action: 'copy-structure', icon: , title: '复制表结构 · DDL' }, { action: 'copy-insert', icon: , title: '复制全表为 INSERT' }, ])} @@ -244,12 +247,14 @@ export type V2TableGroupContextMenuActionKey = export const V2TableGroupContextMenuView: React.FC<{ title?: string; + shortcutPlatform?: ShortcutPlatform; dbName?: string; count?: number; currentSort?: 'name' | 'frequency'; onAction?: (action: V2TableGroupContextMenuActionKey) => void; }> = ({ title = '表 · tables', + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, dbName, count, currentSort = 'name', @@ -272,7 +277,7 @@ export const V2TableGroupContextMenuView: React.FC<{
{renderItems([ - { action: 'new-table', icon: , title: '新建表', kbd: '⌘N', featured: true }, + { action: 'new-table', icon: , title: '新建表', kbd: primaryShortcut('N', shortcutPlatform), featured: true }, ])}
排序
@@ -301,6 +306,7 @@ export type V2DatabaseContextMenuActionKey = export const V2DatabaseContextMenuView: React.FC<{ dbName: string; + shortcutPlatform?: ShortcutPlatform; dialect?: string; supportsSchemaActions?: boolean; supportsStarRocksActions?: boolean; @@ -309,6 +315,7 @@ export const V2DatabaseContextMenuView: React.FC<{ onAction?: (action: V2DatabaseContextMenuActionKey) => void; }> = ({ dbName, + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, dialect, supportsSchemaActions = false, supportsStarRocksActions = false, @@ -332,7 +339,7 @@ export const V2DatabaseContextMenuView: React.FC<{
{renderItems([ - { action: 'new-table', icon: , title: '新建表', kbd: '⌘N', featured: true }, + { action: 'new-table', icon: , title: '新建表', kbd: primaryShortcut('N', shortcutPlatform), featured: true }, ...(supportsSchemaActions ? [{ action: 'new-schema', icon: , title: '新建模式' }] : []), { action: 'new-query', icon: , title: '新建查询' }, { action: 'run-sql', icon: , title: '运行外部 SQL 文件' }, @@ -370,6 +377,13 @@ export const V2DatabaseContextMenuView: React.FC<{ ); }; +const DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM: ShortcutPlatform = 'windows'; + +const primaryShortcut = ( + key: string, + shortcutPlatform: ShortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, +): string => getPrimaryShortcutDisplayLabel(key, shortcutPlatform); + export type V2ConnectionContextMenuActionKey = | 'new-db' | 'refresh' @@ -432,6 +446,7 @@ export const V2ConnectionGroupContextMenuView: React.FC<{ export const V2ConnectionContextMenuView: React.FC<{ connectionName: string; + shortcutPlatform?: ShortcutPlatform; hostSummary?: string; driverLabel?: string; isRedis?: boolean; @@ -440,6 +455,7 @@ export const V2ConnectionContextMenuView: React.FC<{ onAction?: (action: V2ConnectionContextMenuActionKey) => void; }> = ({ connectionName, + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, hostSummary, driverLabel, isRedis = false, @@ -468,12 +484,12 @@ export const V2ConnectionContextMenuView: React.FC<{
{isRedis ? renderItems([ - { action: 'refresh', icon: , title: '刷新连接', kbd: '⌘R', featured: true }, + { action: 'refresh', icon: , title: '刷新连接', kbd: primaryShortcut('R', shortcutPlatform), featured: true }, { action: 'new-command', icon: , title: '新建命令窗口', featured: true }, { action: 'open-monitor', icon: , title: 'Redis 实例监控' }, ]) : renderItems([ - ...(supportsCreateDatabase ? [{ action: 'new-db' as const, icon: , title: '新建数据库', kbd: '⌘N', featured: true }] : []), - { action: 'refresh', icon: , title: '刷新连接', kbd: '⌘R' }, + ...(supportsCreateDatabase ? [{ action: 'new-db' as const, icon: , title: '新建数据库', kbd: primaryShortcut('N', shortcutPlatform), featured: true }] : []), + { action: 'refresh', icon: , title: '刷新连接', kbd: primaryShortcut('R', shortcutPlatform) }, { action: 'new-query', icon: , title: '新建查询' }, { action: 'open-sql-file', icon: , title: '运行外部 SQL 文件' }, ])} @@ -519,6 +535,8 @@ export const V2ConnectionContextMenuView: React.FC<{ export type V2CellContextMenuActionKey = | 'copy-field-name' | 'copy-row-data' + | 'copy-row-for-paste' + | 'paste-row-as-new' | 'copy-column-data' | 'set-null' | 'edit-row' @@ -550,6 +568,7 @@ export type V2ColumnHeaderContextMenuActionKey = export const V2ColumnHeaderContextMenuView: React.FC<{ fieldName: string; + shortcutPlatform?: ShortcutPlatform; columnType?: string; columnComment?: string; sortOrder?: 'ascend' | 'descend' | null; @@ -558,6 +577,7 @@ export const V2ColumnHeaderContextMenuView: React.FC<{ onAction?: (action: V2ColumnHeaderContextMenuActionKey) => void; }> = ({ fieldName, + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, columnType, columnComment, sortOrder, @@ -587,7 +607,7 @@ export const V2ColumnHeaderContextMenuView: React.FC<{
{renderItems([ - { action: 'copy-field-name', icon: , title: '复制字段名称', kbd: '⌘C', featured: true }, + { action: 'copy-field-name', icon: , title: '复制字段名称', kbd: primaryShortcut('C', shortcutPlatform), featured: true }, { action: 'copy-column-data', icon: , title: '复制列数据' }, ])} @@ -622,19 +642,23 @@ export const V2ColumnHeaderContextMenuView: React.FC<{ export const V2CellContextMenuView: React.FC<{ fieldName: string; + shortcutPlatform?: ShortcutPlatform; tableName?: string; rowLabel?: string; selectedRowCount?: number; canModifyData?: boolean; + copiedRowCount?: number; canPasteCopiedColumns?: boolean; supportsCopyInsert?: boolean; onAction?: (action: V2CellContextMenuActionKey) => void; }> = ({ fieldName, + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, tableName, rowLabel, selectedRowCount = 0, canModifyData = false, + copiedRowCount = 0, canPasteCopiedColumns = false, supportsCopyInsert = true, onAction, @@ -658,7 +682,7 @@ export const V2CellContextMenuView: React.FC<{
{renderItems([ - { action: 'copy-field-name', icon: , title: '复制字段名称', kbd: '⌘C', featured: true }, + { action: 'copy-field-name', icon: , title: '复制字段名称', kbd: primaryShortcut('C', shortcutPlatform), featured: true }, ])} {canModifyData && ( @@ -667,6 +691,13 @@ export const V2CellContextMenuView: React.FC<{ {renderItems([ { action: 'set-null', icon: , title: '设置为 NULL' }, { action: 'edit-row', icon: , title: '编辑本行', kbd: '↵' }, + { action: 'copy-row-for-paste', icon: , title: '复制本行为新增行' }, + { + action: 'paste-row-as-new', + icon: , + title: copiedRowCount > 0 ? `粘贴为新增行 (${copiedRowCount})` : '粘贴为新增行', + disabled: copiedRowCount <= 0, + }, { action: 'fill-selected', icon: , diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index b768ee3..8b1dad2 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -3983,9 +3983,19 @@ body[data-ui-version="v2"] .gn-v2-table-context-menu-popup { max-width: calc(100vw - 24px); } +body[data-ui-version="v2"] .gn-v2-sidebar-context-menu-portal { + width: 264px; + max-width: calc(100vw - 24px); +} + +body[data-ui-version="v2"] .gn-v2-table-overview-context-menu-portal { + width: 264px; + max-width: calc(100vw - 24px); +} + body[data-ui-version="v2"] .gn-v2-table-context-menu { width: 264px; - max-height: min(582px, calc(100vh - 28px)); + max-height: min(582px, var(--gn-v2-context-menu-max-height, calc(100vh - 28px))); display: flex; flex-direction: column; box-sizing: border-box; @@ -4006,10 +4016,10 @@ body[data-ui-version="v2"] .gn-v2-context-menu-body { max-height: none; overflow-y: auto; overflow-x: hidden; - padding: 4px 4px 12px; + padding: 4px 4px 20px; box-sizing: border-box; overscroll-behavior: contain; - scroll-padding-bottom: 12px; + scroll-padding-bottom: 20px; } body[data-ui-version="v2"] .gn-v2-context-menu-body::-webkit-scrollbar { @@ -4351,6 +4361,15 @@ body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-n background: var(--gn-bg-panel-2); } +body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab { + min-height: 34px; + padding: 4px 10px !important; +} + +body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-nav-list { + align-items: stretch; +} + body[data-ui-version="v2"] .gn-v2-query-empty, body[data-ui-version="v2"] .gn-v2-query-success { display: flex;