feat(sidebar): 优化对象菜单与旧版布局交互

- 为已存查询右键菜单补充重命名能力并同步已打开标签

- 优化 v2 侧栏与表概览右键菜单定位,避免底部遮挡

- 精简旧版数据视图工具栏布局并统一快捷键显示

- 补充侧栏与表概览菜单回归测试
This commit is contained in:
Syngnat
2026-05-31 22:31:47 +08:00
parent 4cfa4bc63f
commit e687ae2819
6 changed files with 550 additions and 109 deletions

View File

@@ -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 图中查看');

View File

@@ -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}

View 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');
});
});

View File

@@ -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>
);
};

View File

@@ -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 />,

View File

@@ -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;