mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-13 01:49:41 +08:00
✨ feat(sidebar): 优化对象菜单与旧版布局交互
- 为已存查询右键菜单补充重命名能力并同步已打开标签 - 优化 v2 侧栏与表概览右键菜单定位,避免底部遮挡 - 精简旧版数据视图工具栏布局并统一快捷键显示 - 补充侧栏与表概览菜单回归测试
This commit is contained in:
@@ -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('<kbd>⌘</kbd>');
|
||||
expect(markup).toContain('<kbd>Ctrl</kbd>');
|
||||
expect(markup).toContain('<kbd>K</kbd>');
|
||||
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(
|
||||
<V2TableContextMenuView
|
||||
@@ -839,7 +864,7 @@ describe('Sidebar locate toolbar', () => {
|
||||
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 图中查看');
|
||||
|
||||
@@ -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<TreeNode[]>([]);
|
||||
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<Set<string>>(new Set());
|
||||
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
|
||||
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
|
||||
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
|
||||
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<any>(null);
|
||||
const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false);
|
||||
const [renameSavedQueryForm] = Form.useForm();
|
||||
const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState<SavedQuery | null>(null);
|
||||
|
||||
// Connection Tag Modals
|
||||
const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false);
|
||||
@@ -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 (
|
||||
<V2TableContextMenuView
|
||||
tableName={tableName}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
stats={stats}
|
||||
isPinned={isPinned}
|
||||
supportsTruncate={supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver)}
|
||||
@@ -5952,6 +6049,7 @@ const Sidebar: React.FC<{
|
||||
return (
|
||||
<V2TableGroupContextMenuView
|
||||
title="表 · tables"
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
dbName={String(groupData.dbName || '')}
|
||||
count={Array.isArray(node.children) ? node.children.length : 0}
|
||||
currentSort={currentSort}
|
||||
@@ -5969,6 +6067,7 @@ const Sidebar: React.FC<{
|
||||
return (
|
||||
<V2DatabaseContextMenuView
|
||||
dbName={String(node.dataRef?.dbName || node.title || '')}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
dialect={dialect}
|
||||
supportsSchemaActions={isPostgresSchemaDialect(dialect)}
|
||||
supportsStarRocksActions={dialect === 'starrocks'}
|
||||
@@ -5989,6 +6088,7 @@ const Sidebar: React.FC<{
|
||||
return (
|
||||
<V2ConnectionContextMenuView
|
||||
connectionName={String(conn?.name || node.title || '未命名连接')}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
hostSummary={resolveConnectionHostSummary(conn?.config)}
|
||||
driverLabel={resolveConnectionIconType(conn)}
|
||||
isRedis={conn?.config?.type === 'redis'}
|
||||
@@ -6017,6 +6117,55 @@ const Sidebar: React.FC<{
|
||||
/>
|
||||
);
|
||||
|
||||
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: <EditOutlined />,
|
||||
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<{
|
||||
<SearchOutlined />
|
||||
<span>搜索表、连接、动作... 或问 AI</span>
|
||||
<span className="gn-v2-search-shortcut" aria-hidden="true">
|
||||
<kbd>⌘</kbd>
|
||||
<kbd>{primaryShortcutModifierLabel}</kbd>
|
||||
<kbd>K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
@@ -8049,21 +8220,34 @@ const Sidebar: React.FC<{
|
||||
</div>
|
||||
{renderV2CommandSearchOverlay()}
|
||||
|
||||
{contextMenu && (
|
||||
{contextMenu?.kind && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={contextMenuPortalRef}
|
||||
className={`gn-v2-sidebar-context-menu-portal ${contextMenu.rootClassName || ''}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 10000,
|
||||
width: contextMenu.overlayStyle?.width ?? SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH,
|
||||
maxWidth: contextMenu.overlayStyle?.maxWidth ?? 'calc(100vw - 24px)',
|
||||
['--gn-v2-context-menu-max-height' as any]: `${contextMenu.maxHeight ?? SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT}px`,
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{renderV2SidebarContextMenuContent(contextMenu)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{contextMenu && !contextMenu.kind && (
|
||||
<Dropdown
|
||||
menu={{ items: contextMenu.items }}
|
||||
open={true}
|
||||
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
||||
trigger={['contextMenu']}
|
||||
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<{
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`重命名查询${renameSavedQueryTarget?.name ? ` (${renameSavedQueryTarget.name})` : ''}`}
|
||||
open={isRenameSavedQueryModalOpen}
|
||||
onOk={handleRenameSavedQuery}
|
||||
onCancel={() => {
|
||||
setIsRenameSavedQueryModalOpen(false);
|
||||
setRenameSavedQueryTarget(null);
|
||||
renameSavedQueryForm.resetFields();
|
||||
}}
|
||||
okText="重命名"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={renameSavedQueryForm} layout="vertical">
|
||||
<Form.Item name="name" label="查询名称" rules={[{ required: true, message: '请输入查询名称' }]}>
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={renderSidebarModalTitle(<TableOutlined />, "批量操作表", "按对象批量导出结构、数据或完整备份。")}
|
||||
open={isBatchModalOpen}
|
||||
|
||||
28
frontend/src/components/TableOverview.context-menu.test.ts
Normal file
28
frontend/src/components/TableOverview.context-menu.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<TableOverviewProps> = ({ tab }) => {
|
||||
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
|
||||
const darkMode = theme === 'dark';
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
|
||||
const [tables, setTables] = useState<TableStatRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -206,7 +249,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(isV2Ui ? 'card' : 'list');
|
||||
const [openContextMenuTable, setOpenContextMenuTable] = useState<string | null>(null);
|
||||
const [v2ContextMenu, setV2ContextMenu] = useState<OverviewContextMenuState | null>(null);
|
||||
const v2ContextMenuPortalRef = useRef<HTMLDivElement | null>(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<TableOverviewProps> = ({ 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<TableOverviewProps> = ({ tab }) => {
|
||||
const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => (
|
||||
<V2TableContextMenuView
|
||||
tableName={table.name}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
stats={{
|
||||
rowCount: table.rows,
|
||||
dataLength: table.dataSize,
|
||||
@@ -710,11 +815,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ 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: <ConsoleSqlOutlined />, onClick: () => openQueryForTable(table.name) },
|
||||
@@ -768,61 +873,66 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardTable = (t: TableStatRow) => (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
|
||||
open={isV2Ui ? openContextMenuTable === t.name : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => 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) => (
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-table-card' : undefined}
|
||||
onDoubleClick={() => 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; }}
|
||||
>
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-table-card' : undefined}
|
||||
onDoubleClick={() => 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; }}
|
||||
>
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
|
||||
{t.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{t.comment && (
|
||||
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
|
||||
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.comment}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
|
||||
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
|
||||
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
|
||||
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
|
||||
</div>
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-table-size-bar">
|
||||
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
|
||||
{t.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{t.comment && (
|
||||
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
|
||||
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.comment}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
|
||||
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
|
||||
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
|
||||
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
|
||||
</div>
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-table-size-bar">
|
||||
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardTable = (t: TableStatRow) => {
|
||||
if (isV2Ui) {
|
||||
return <React.Fragment key={t.name}>{renderCardTableContent(t)}</React.Fragment>;
|
||||
}
|
||||
return (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{ items: buildLegacyTableContextMenuItems(t) }}
|
||||
>
|
||||
{renderCardTableContent(t)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
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<TableOverviewProps> = ({ 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 (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
|
||||
open={isV2Ui ? openContextMenuTable === t.name : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => 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 = (
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-table-row' : undefined}
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
@@ -931,6 +1032,19 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isV2Ui) {
|
||||
return <React.Fragment key={t.name}>{content}</React.Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{ items: buildLegacyTableContextMenuItems(t) }}
|
||||
>
|
||||
{content}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -1061,6 +1175,27 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isV2Ui && v2ContextMenu && v2ContextMenuTable && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={v2ContextMenuPortalRef}
|
||||
className="gn-v2-table-overview-context-menu-portal gn-v2-table-context-menu-popup"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: v2ContextMenu.x,
|
||||
top: v2ContextMenu.y,
|
||||
zIndex: 10000,
|
||||
width: OVERVIEW_CONTEXT_MENU_WIDTH,
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
['--gn-v2-context-menu-max-height' as any]: `${v2ContextMenu.maxHeight}px`,
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{renderV2OverviewTableContextMenu(v2ContextMenuTable)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: <TableOutlined />, title: '查看数据', kbd: '↵', featured: true },
|
||||
{ action: isPinned ? 'unpin-table' : 'pin-table', icon: <PushpinOutlined />, title: isPinned ? '取消置顶' : '置顶表', kbd: isPinned ? '已置顶' : undefined, selected: isPinned },
|
||||
{ action: 'design-table', icon: <EditOutlined />, title: '设计表 · 字段 / 索引 / 外键', kbd: '⌘D' },
|
||||
{ action: 'open-new-tab', icon: <FileAddOutlined />, title: '在新标签打开', kbd: '⌘↵' },
|
||||
{ action: 'design-table', icon: <EditOutlined />, title: '设计表 · 字段 / 索引 / 外键', kbd: primaryShortcut('D', shortcutPlatform) },
|
||||
{ action: 'open-new-tab', icon: <FileAddOutlined />, title: '在新标签打开', kbd: primaryShortcut('Enter', shortcutPlatform) },
|
||||
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
|
||||
])}
|
||||
|
||||
@@ -209,7 +212,7 @@ export const V2TableContextMenuView: React.FC<{
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">复制</div>
|
||||
{renderItems([
|
||||
{ action: 'copy-table-name', icon: <CopyOutlined />, title: '复制表名', kbd: '⌘C' },
|
||||
{ action: 'copy-table-name', icon: <CopyOutlined />, title: '复制表名', kbd: primaryShortcut('C', shortcutPlatform) },
|
||||
{ action: 'copy-structure', icon: <CopyOutlined />, title: '复制表结构 · DDL' },
|
||||
{ action: 'copy-insert', icon: <CopyOutlined />, 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<{
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: '⌘N', featured: true },
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: primaryShortcut('N', shortcutPlatform), featured: true },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">排序</div>
|
||||
@@ -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<{
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: '⌘N', featured: true },
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: primaryShortcut('N', shortcutPlatform), featured: true },
|
||||
...(supportsSchemaActions ? [{ action: 'new-schema', icon: <FolderAddOutlined />, title: '新建模式' }] : []),
|
||||
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
|
||||
{ action: 'run-sql', icon: <FileAddOutlined />, 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<{
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{isRedis ? renderItems([
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: '⌘R', featured: true },
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: primaryShortcut('R', shortcutPlatform), featured: true },
|
||||
{ action: 'new-command', icon: <ConsoleSqlOutlined />, title: '新建命令窗口', featured: true },
|
||||
{ action: 'open-monitor', icon: <DashboardOutlined />, title: 'Redis 实例监控' },
|
||||
]) : renderItems([
|
||||
...(supportsCreateDatabase ? [{ action: 'new-db' as const, icon: <DatabaseOutlined />, title: '新建数据库', kbd: '⌘N', featured: true }] : []),
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: '⌘R' },
|
||||
...(supportsCreateDatabase ? [{ action: 'new-db' as const, icon: <DatabaseOutlined />, title: '新建数据库', kbd: primaryShortcut('N', shortcutPlatform), featured: true }] : []),
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: primaryShortcut('R', shortcutPlatform) },
|
||||
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
|
||||
{ action: 'open-sql-file', icon: <FileAddOutlined />, 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<{
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: '⌘C', featured: true },
|
||||
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: primaryShortcut('C', shortcutPlatform), featured: true },
|
||||
{ action: 'copy-column-data', icon: <CopyOutlined />, 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<{
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: '⌘C', featured: true },
|
||||
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: primaryShortcut('C', shortcutPlatform), featured: true },
|
||||
])}
|
||||
|
||||
{canModifyData && (
|
||||
@@ -667,6 +691,13 @@ export const V2CellContextMenuView: React.FC<{
|
||||
{renderItems([
|
||||
{ action: 'set-null', icon: <ClearOutlined />, title: '设置为 NULL' },
|
||||
{ action: 'edit-row', icon: <EditOutlined />, title: '编辑本行', kbd: '↵' },
|
||||
{ action: 'copy-row-for-paste', icon: <CopyOutlined />, title: '复制本行为新增行' },
|
||||
{
|
||||
action: 'paste-row-as-new',
|
||||
icon: <VerticalAlignBottomOutlined />,
|
||||
title: copiedRowCount > 0 ? `粘贴为新增行 (${copiedRowCount})` : '粘贴为新增行',
|
||||
disabled: copiedRowCount <= 0,
|
||||
},
|
||||
{
|
||||
action: 'fill-selected',
|
||||
icon: <VerticalAlignBottomOutlined />,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user