mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-15 11:14:31 +08:00
🎨 style(context-menu): 统一工作台右键菜单视觉规范
- 复用表对象菜单的上下文标题、功能分组与快捷键布局 - 统一查询标签、结果标签、工具栏、顶栏、Redis 与 Monaco 菜单 - 补充菜单结构、交互和视觉规范回归测试 Refs #879
This commit is contained in:
@@ -435,6 +435,10 @@ vi.mock('@ant-design/icons', () => {
|
||||
ClearOutlined: Icon,
|
||||
CopyOutlined: Icon,
|
||||
DiffOutlined: Icon,
|
||||
ExportOutlined: Icon,
|
||||
TableOutlined: Icon,
|
||||
ArrowLeftOutlined: Icon,
|
||||
ArrowRightOutlined: Icon,
|
||||
PlayCircleOutlined: Icon,
|
||||
SaveOutlined: Icon,
|
||||
FormatPainterOutlined: Icon,
|
||||
@@ -504,16 +508,26 @@ vi.mock('antd', () => {
|
||||
Input,
|
||||
Segmented: () => null,
|
||||
Form,
|
||||
Dropdown: ({ children, menu }: any) => (
|
||||
<>
|
||||
{children}
|
||||
{menu?.items?.map((item: any) => (
|
||||
item?.type === 'divider'
|
||||
? null
|
||||
: <button key={item.key} type="button" disabled={item.disabled} onClick={item.onClick}>{item.label}</button>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
Dropdown: ({ children, menu }: any) => {
|
||||
const renderMenuItems = (items: any[] = []): React.ReactNode => items.map((item: any) => {
|
||||
if (item?.type === 'divider') return null;
|
||||
if (item?.type === 'group') {
|
||||
return (
|
||||
<React.Fragment key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
{renderMenuItems(item.children)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return <button key={item.key} type="button" disabled={item.disabled} onClick={item.onClick}>{item.label}</button>;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{renderMenuItems(menu?.items)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
Select: () => null,
|
||||
Tabs: ({ activeKey, items, onChange, tabBarExtraContent }: any) => {
|
||||
|
||||
@@ -122,6 +122,7 @@ import {
|
||||
} from '../utils/resultDiff/viewDataVerify';
|
||||
import { SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS } from './QueryEditorTransactionSettings';
|
||||
import QueryEditorTransactionToolbar from './QueryEditorTransactionToolbar';
|
||||
import { decorateV2MonacoContextMenu } from './common/V2ActionMenuPopup';
|
||||
import QueryEditorToolbar, {
|
||||
formatQueryExecutionElapsed,
|
||||
resolveQueryExecutionSpeedIcon,
|
||||
@@ -4793,6 +4794,28 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
inlineSuggest: { enabled: false },
|
||||
} : mountedEditorOptions);
|
||||
|
||||
if (isV2Ui && typeof editor.onContextMenu === 'function') {
|
||||
editor.onContextMenu(() => {
|
||||
const decorateContextMenu = () => {
|
||||
const connectionName = connectionsRef.current.find(
|
||||
(connection) => connection.id === currentConnectionIdRef.current,
|
||||
)?.name;
|
||||
const contextMeta = [connectionName, currentDbRef.current]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
decorateV2MonacoContextMenu(
|
||||
translate('tab_manager.kind_badge.query'),
|
||||
contextMeta || translate('query_editor.placeholder.database'),
|
||||
);
|
||||
};
|
||||
// Monaco appends the menu asynchronously after dispatching onContextMenu.
|
||||
// Retry across the next paint window so the first open is decorated too.
|
||||
window.setTimeout(decorateContextMenu, 0);
|
||||
window.setTimeout(decorateContextMenu, 48);
|
||||
window.setTimeout(decorateContextMenu, 120);
|
||||
});
|
||||
}
|
||||
|
||||
aiInlineGhostVisibleContextKeyRef.current = editor.createContextKey?.(
|
||||
QUERY_EDITOR_AI_INLINE_CONTEXT_KEY,
|
||||
false,
|
||||
@@ -7630,35 +7653,47 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
|
||||
const formatSettingsMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'upper',
|
||||
label: translate('query_editor.format.keyword_upper'),
|
||||
icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined,
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'upper' })
|
||||
},
|
||||
{
|
||||
key: 'lower',
|
||||
label: translate('query_editor.format.keyword_lower'),
|
||||
icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined,
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'restore-last-format',
|
||||
label: translate('query_editor.format.restore_last_format'),
|
||||
disabled: !tab.formatRestoreSnapshot?.query,
|
||||
onClick: handleRestoreLastFormat,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'snippet-settings',
|
||||
label: translate('query_editor.format.snippet_settings'),
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')),
|
||||
type: 'group',
|
||||
key: 'format-actions',
|
||||
label: translate('query_editor.action.format_sql'),
|
||||
children: [
|
||||
{
|
||||
key: 'upper',
|
||||
label: translate('query_editor.format.keyword_upper'),
|
||||
icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined,
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'upper' }),
|
||||
},
|
||||
{
|
||||
key: 'lower',
|
||||
label: translate('query_editor.format.keyword_lower'),
|
||||
icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined,
|
||||
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' }),
|
||||
},
|
||||
{
|
||||
key: 'restore-last-format',
|
||||
label: translate('query_editor.format.restore_last_format'),
|
||||
disabled: !tab.formatRestoreSnapshot?.query,
|
||||
onClick: handleRestoreLastFormat,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
label: translate('query_editor.format.shortcut_settings'),
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-shortcut-settings')),
|
||||
type: 'group',
|
||||
key: 'format-settings',
|
||||
label: translate('settings.title'),
|
||||
children: [
|
||||
{
|
||||
key: 'snippet-settings',
|
||||
label: translate('query_editor.format.snippet_settings'),
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')),
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
label: translate('query_editor.format.shortcut_settings'),
|
||||
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-shortcut-settings')),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10415,60 +10450,73 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
), [currentDb, elasticsearchServerMajor, insertElasticsearchConsoleTemplate]);
|
||||
|
||||
const saveMoreMenuItems: MenuProps['items'] = [
|
||||
...(currentSavedQuery && !tab.filePath ? [{
|
||||
key: 'save-query-as',
|
||||
label: (
|
||||
<span>
|
||||
{translate('query_editor.action.save_as')}
|
||||
{saveQueryAsShortcutBinding?.enabled && saveQueryAsShortcutBinding.combo && (
|
||||
<span style={{ marginLeft: 8, color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
|
||||
{getShortcutDisplayLabel(saveQueryAsShortcutBinding.combo, activeShortcutPlatform)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: handleSaveQueryAs,
|
||||
}] : []),
|
||||
{
|
||||
key: 'rename-query',
|
||||
label: translate('query_editor.action.rename_query'),
|
||||
disabled: !!tab.filePath,
|
||||
onClick: handleRenameQuery,
|
||||
type: 'group',
|
||||
key: 'query-actions',
|
||||
label: translate('tab_manager.kind_badge.query'),
|
||||
children: [
|
||||
...(currentSavedQuery && !tab.filePath ? [{
|
||||
key: 'save-query-as',
|
||||
label: (
|
||||
<span className="gn-v2-context-menu-item-title">
|
||||
{translate('query_editor.action.save_as')}
|
||||
{saveQueryAsShortcutBinding?.enabled && saveQueryAsShortcutBinding.combo && (
|
||||
<span className="gn-v2-context-menu-kbd">
|
||||
{getShortcutDisplayLabel(saveQueryAsShortcutBinding.combo, activeShortcutPlatform)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: handleSaveQueryAs,
|
||||
}] : []),
|
||||
{
|
||||
key: 'rename-query',
|
||||
label: translate('query_editor.action.rename_query'),
|
||||
disabled: !!tab.filePath,
|
||||
onClick: handleRenameQuery,
|
||||
},
|
||||
{
|
||||
key: 'export-sql-file',
|
||||
label: translate('query_editor.action.export_sql_file'),
|
||||
onClick: () => void handleExportSQLFile(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'export-sql-file',
|
||||
label: translate('query_editor.action.export_sql_file'),
|
||||
onClick: () => void handleExportSQLFile(),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'diagnose-query',
|
||||
label: (
|
||||
<span>
|
||||
{translate('app.shortcuts.action.diagnoseQuery.label' as any)}
|
||||
{diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && (
|
||||
<span style={{ marginLeft: 8, color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
|
||||
{getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)}
|
||||
type: 'group',
|
||||
key: 'analysis-actions',
|
||||
label: translate('tab_manager.kind_badge.sql_analysis'),
|
||||
children: [
|
||||
{
|
||||
key: 'diagnose-query',
|
||||
label: (
|
||||
<span className="gn-v2-context-menu-item-title">
|
||||
{translate('app.shortcuts.action.diagnoseQuery.label' as any)}
|
||||
{diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && (
|
||||
<span className="gn-v2-context-menu-kbd">
|
||||
{getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
disabled: !currentConnectionCapabilities.supportsExplainDiagnosis,
|
||||
onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()),
|
||||
},
|
||||
{
|
||||
key: 'show-slow-queries',
|
||||
label: (
|
||||
<span>
|
||||
{translate('app.shortcuts.action.showSlowQueries.label' as any)}
|
||||
{showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && (
|
||||
<span style={{ marginLeft: 8, color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
|
||||
{getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)}
|
||||
),
|
||||
disabled: !currentConnectionCapabilities.supportsExplainDiagnosis,
|
||||
onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()),
|
||||
},
|
||||
{
|
||||
key: 'show-slow-queries',
|
||||
label: (
|
||||
<span className="gn-v2-context-menu-item-title">
|
||||
{translate('app.shortcuts.action.showSlowQueries.label' as any)}
|
||||
{showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && (
|
||||
<span className="gn-v2-context-menu-kbd">
|
||||
{getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => openSqlAnalysisWorkbench('slow-query'),
|
||||
),
|
||||
onClick: () => openSqlAnalysisWorkbench('slow-query'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Dropdown, Segmented, Tag, Tabs, Tooltip, message, type MenuProps } from 'antd';
|
||||
import { BugOutlined, ClearOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, PushpinOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined, BugOutlined, ClearOutlined, CloseOutlined, CopyOutlined, DiffOutlined, ExportOutlined, EyeInvisibleOutlined, PushpinOutlined, RobotOutlined, TableOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useStore } from '../store';
|
||||
import type { EditRowLocator } from '../utils/rowLocator';
|
||||
@@ -22,6 +22,7 @@ import DetachDragPreview, {
|
||||
} from './DetachDragPreview';
|
||||
import DataGrid from './DataGrid';
|
||||
import LogPanel from './LogPanel';
|
||||
import { renderV2ActionMenuPopup } from './common/V2ActionMenuPopup';
|
||||
|
||||
export type OpenResultInWindowPreferred = Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>;
|
||||
|
||||
@@ -496,40 +497,67 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
const hasClosableResult = resultSets.some((item) => !item.pinned);
|
||||
return [
|
||||
{
|
||||
key: result?.pinned ? 'unpin' : 'pin',
|
||||
label: t(result?.pinned
|
||||
? 'query_editor.results_panel.menu.unpin'
|
||||
: 'query_editor.results_panel.menu.pin'),
|
||||
onClick: () => onResultPinnedChange(key, !result?.pinned),
|
||||
type: 'group',
|
||||
key: 'result-actions',
|
||||
label: t('query_editor.action.results'),
|
||||
children: [
|
||||
{
|
||||
key: result?.pinned ? 'unpin' : 'pin',
|
||||
icon: <PushpinOutlined />,
|
||||
label: t(result?.pinned
|
||||
? 'query_editor.results_panel.menu.unpin'
|
||||
: 'query_editor.results_panel.menu.pin'),
|
||||
onClick: () => onResultPinnedChange(key, !result?.pinned),
|
||||
},
|
||||
...(onOpenResultInWindow
|
||||
? [{
|
||||
key: 'open-in-window',
|
||||
icon: <ExportOutlined />,
|
||||
label: t('query_editor.results_panel.menu.open_in_window'),
|
||||
onClick: () => onOpenResultInWindow(key),
|
||||
}]
|
||||
: []),
|
||||
...(onCompareResult
|
||||
? [{
|
||||
key: 'compare-results',
|
||||
icon: <DiffOutlined />,
|
||||
label: t('query_editor.results_panel.menu.compare_results'),
|
||||
disabled: comparableCount < 2,
|
||||
onClick: () => onCompareResult(key),
|
||||
}]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
key: 'close-actions',
|
||||
label: t('common.close'),
|
||||
children: [
|
||||
{ key: 'close-other', icon: <CloseOutlined />, label: t('query_editor.results_panel.menu.close_other'), disabled: !hasClosableOtherResult, onClick: () => onCloseOtherResultTabs(key) },
|
||||
{ key: 'close-left', icon: <ArrowLeftOutlined />, label: t('query_editor.results_panel.menu.close_left'), disabled: !hasClosableResultToLeft, onClick: () => onCloseResultTabsToLeft(key) },
|
||||
{ key: 'close-right', icon: <ArrowRightOutlined />, label: t('query_editor.results_panel.menu.close_right'), disabled: !hasClosableResultToRight, onClick: () => onCloseResultTabsToRight(key) },
|
||||
{ key: 'close-all', icon: <CloseOutlined />, label: t('query_editor.results_panel.menu.close_all'), disabled: !hasClosableResult, onClick: onCloseAllResultTabs },
|
||||
],
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
...(onOpenResultInWindow
|
||||
? [{
|
||||
key: 'open-in-window',
|
||||
label: t('query_editor.results_panel.menu.open_in_window'),
|
||||
onClick: () => onOpenResultInWindow(key),
|
||||
}, { type: 'divider' as const }]
|
||||
: []),
|
||||
...(onCompareResult
|
||||
? [{
|
||||
key: 'compare-results',
|
||||
label: t('query_editor.results_panel.menu.compare_results'),
|
||||
disabled: comparableCount < 2,
|
||||
onClick: () => onCompareResult(key),
|
||||
}, { type: 'divider' as const }]
|
||||
: []),
|
||||
{ key: 'close-other', label: t('query_editor.results_panel.menu.close_other'), disabled: !hasClosableOtherResult, onClick: () => onCloseOtherResultTabs(key) },
|
||||
{ key: 'close-left', label: t('query_editor.results_panel.menu.close_left'), disabled: !hasClosableResultToLeft, onClick: () => onCloseResultTabsToLeft(key) },
|
||||
{ key: 'close-right', label: t('query_editor.results_panel.menu.close_right'), disabled: !hasClosableResultToRight, onClick: () => onCloseResultTabsToRight(key) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'close-all', label: t('query_editor.results_panel.menu.close_all'), disabled: !hasClosableResult, onClick: onCloseAllResultTabs },
|
||||
];
|
||||
}
|
||||
|
||||
const resultTabItems = resultSets.map((rs, idx) => ({
|
||||
key: rs.key,
|
||||
label: (
|
||||
<Dropdown menu={{ items: buildResultTabMenuItems(rs.key, idx) }} trigger={['contextMenu']} rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}>
|
||||
<Dropdown
|
||||
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
|
||||
trigger={['contextMenu']}
|
||||
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, isV2Ui, {
|
||||
title: rs.resultType === 'message'
|
||||
? t('query_editor.results_panel.tab.message', { index: idx + 1 })
|
||||
: t('query_editor.results_panel.tab.result', { index: idx + 1 }),
|
||||
meta: Array.isArray(rs.rows) ? `${rs.rows.length} × ${rs.columns.length}` : currentDb,
|
||||
icon: <TableOutlined />,
|
||||
badge: currentDb || undefined,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={`query-result-tab-label${onOpenResultInWindow ? ' is-detachable' : ''}${draggingResultKey === rs.key ? ' is-dragging-detach' : ''}`}
|
||||
title={onOpenResultInWindow ? t('query_editor.results_panel.menu.open_in_window') : undefined}
|
||||
|
||||
@@ -32,6 +32,47 @@ describe('QueryEditorToolbar layout', () => {
|
||||
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair {');
|
||||
});
|
||||
|
||||
it('uses the table context-menu density across v2 action menus', () => {
|
||||
const css = readV2ThemeCss();
|
||||
const baseTokensCss = css.slice(
|
||||
css.indexOf('body[data-ui-version="v2"] {'),
|
||||
css.indexOf('body[data-ui-version="v2"][data-platform="darwin"] {'),
|
||||
);
|
||||
const dropdownItemCss = css.slice(
|
||||
css.indexOf('/* Compact action menus share the table context-menu geometry. */'),
|
||||
css.indexOf('/* Icon + label share one baseline/vertical center'),
|
||||
);
|
||||
const titlebarMenuCss = css.slice(
|
||||
css.indexOf('/* Title-bar more menu: leaf rows and submenu rows share the same hover fill + alignment */'),
|
||||
css.indexOf('body[data-ui-version="v2"] .gn-v2-titlebar-quick-dropdown .ant-dropdown-menu-item:hover'),
|
||||
);
|
||||
const tabMenuCss = css.slice(
|
||||
css.indexOf('body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu {'),
|
||||
css.indexOf('/* ─── V2 DataGrid: toolbar, smart filters, table, statusbar ─ */'),
|
||||
);
|
||||
const tableMenuItemCss = css.slice(
|
||||
css.indexOf('body[data-ui-version="v2"] .gn-v2-context-menu-item {'),
|
||||
css.indexOf('body[data-ui-version="v2"] .gn-v2-context-menu-item:hover {'),
|
||||
);
|
||||
const monacoMenuCss = css.slice(
|
||||
css.indexOf('/* Monaco context menu follows the same compact action-menu surface. */'),
|
||||
css.indexOf('/* Nested dropdown menus: collapse double border, no glow/halo */'),
|
||||
);
|
||||
|
||||
expect(baseTokensCss).toContain('--gn-v2-menu-row-height: 28px;');
|
||||
expect(baseTokensCss).toContain('--gn-v2-menu-item-radius: 5px;');
|
||||
expect(dropdownItemCss).toContain('height: var(--gn-v2-menu-row-height) !important;');
|
||||
expect(dropdownItemCss).toContain('min-height: var(--gn-v2-menu-row-height) !important;');
|
||||
expect(dropdownItemCss).toContain('box-sizing: border-box !important;');
|
||||
expect(dropdownItemCss).toContain('padding: 4px 8px !important;');
|
||||
expect(titlebarMenuCss).toContain('min-height: var(--gn-v2-menu-row-height) !important;');
|
||||
expect(tabMenuCss).not.toContain('min-height: 34px');
|
||||
expect(tabMenuCss).not.toContain('.ant-dropdown-menu-item:first-child');
|
||||
expect(tableMenuItemCss).toContain('height: var(--gn-v2-menu-row-height);');
|
||||
expect(monacoMenuCss).toContain('min-height: var(--gn-v2-menu-row-height) !important;');
|
||||
expect(monacoMenuCss).toContain('background: var(--gn-bg-panel) !important;');
|
||||
});
|
||||
|
||||
it('shares the active theme surface across the SQL toolbar and Monaco editor', () => {
|
||||
const css = readV2ThemeCss();
|
||||
const defaultMonacoCss = css.slice(
|
||||
@@ -268,4 +309,20 @@ describe('QueryEditorToolbar layout', () => {
|
||||
expect(css).toContain('text-overflow: ellipsis;');
|
||||
expect(css).toContain('white-space: nowrap;');
|
||||
});
|
||||
|
||||
it('uses the table context-menu visual grammar for every v2 action popup', () => {
|
||||
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
|
||||
const queryEditorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
|
||||
const sharedPopupSource = readFileSync(new URL('./common/V2ActionMenuPopup.tsx', import.meta.url), 'utf8');
|
||||
const css = readV2ThemeCss();
|
||||
|
||||
expect(toolbarSource).toContain('renderV2ActionMenuPopup');
|
||||
expect(queryEditorSource).toContain('decorateV2MonacoContextMenu');
|
||||
expect(sharedPopupSource).toContain('gn-v2-context-menu-header gn-v2-action-menu-header');
|
||||
expect(sharedPopupSource).toContain('gn-v2-context-menu-engine-pill');
|
||||
expect(css).toContain('.gn-v2-action-menu-surface {');
|
||||
expect(css).toContain('.gn-v2-action-menu-body .ant-dropdown-menu-item-group-title {');
|
||||
expect(css).toContain('.gn-v2-action-menu-body .ant-dropdown-menu-item:not(:has(.ant-dropdown-menu-item-icon))::before');
|
||||
expect(css).toContain('.monaco-menu > .gn-v2-monaco-context-menu-header {');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import QueryEditorTransactionSettings, {
|
||||
type SqlEditorCommitMode,
|
||||
} from "./QueryEditorTransactionSettings";
|
||||
import { renderV2ActionMenuPopup } from './common/V2ActionMenuPopup';
|
||||
|
||||
export type QueryEditorMode = "sql" | "elasticsearch";
|
||||
|
||||
@@ -304,6 +305,11 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
? t("query_editor.elasticsearch.action.ai")
|
||||
: `AI · ${t("query_editor.action.more")}`;
|
||||
const formatSettingsTitle = `${t("query_editor.action.format_sql")} · ${t("settings.title")}`;
|
||||
const activeConnectionLabel = queryCapableConnections.find(
|
||||
(connection) => connection.id === currentConnectionId,
|
||||
)?.name;
|
||||
const menuContextMeta = [activeConnectionLabel, currentDb].filter(Boolean).join(' · ')
|
||||
|| t('tab_manager.kind_badge.query');
|
||||
const aiMenuItems: MenuProps["items"] = isElasticsearchMode
|
||||
? [
|
||||
{
|
||||
@@ -350,16 +356,20 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
const moreMenuItems: MenuProps["items"] = isV2Ui
|
||||
? [
|
||||
...baseMoreMenuItems,
|
||||
...(baseMoreMenuItems.length > 0 ? [{ type: "divider" as const }] : []),
|
||||
{
|
||||
key: "toggle-result-panel",
|
||||
label: toggleResultPanelTitle,
|
||||
icon: isResultPanelVisible ? (
|
||||
<EyeInvisibleOutlined />
|
||||
) : (
|
||||
<EyeOutlined />
|
||||
),
|
||||
onClick: onToggleResultPanelVisibility,
|
||||
type: 'group',
|
||||
key: 'result-visibility',
|
||||
label: t('query_editor.action.results'),
|
||||
children: [{
|
||||
key: "toggle-result-panel",
|
||||
label: toggleResultPanelTitle,
|
||||
icon: isResultPanelVisible ? (
|
||||
<EyeInvisibleOutlined />
|
||||
) : (
|
||||
<EyeOutlined />
|
||||
),
|
||||
onClick: onToggleResultPanelVisibility,
|
||||
}],
|
||||
},
|
||||
]
|
||||
: baseMoreMenuItems;
|
||||
@@ -437,11 +447,17 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
open={isV2Ui && openToolbarMenu === "templates" ? false : undefined}
|
||||
>
|
||||
<span className={isV2Ui ? "gn-v2-query-toolbar-menu-trigger" : undefined}>
|
||||
<Dropdown
|
||||
menu={{ items: templateMenuItems }}
|
||||
placement="bottomLeft"
|
||||
trigger={["click"]}
|
||||
open={isV2Ui ? openToolbarMenu === "templates" : undefined}
|
||||
<Dropdown
|
||||
menu={{ items: templateMenuItems }}
|
||||
placement="bottomLeft"
|
||||
trigger={["click"]}
|
||||
rootClassName={isV2Ui ? 'gn-v2-action-menu-popup-host' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, isV2Ui, {
|
||||
title: t('query_editor.elasticsearch.action.templates'),
|
||||
meta: menuContextMeta,
|
||||
icon: <DownOutlined />,
|
||||
})}
|
||||
open={isV2Ui ? openToolbarMenu === "templates" : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("templates", open) : undefined}
|
||||
>
|
||||
<Button
|
||||
@@ -618,6 +634,13 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
menu={{ items: aiMenuItems }}
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
rootClassName={isV2Ui ? 'gn-v2-action-menu-popup-host' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, isV2Ui, {
|
||||
title: aiMoreTitle,
|
||||
meta: menuContextMeta,
|
||||
icon: <RobotOutlined />,
|
||||
badge: 'AI',
|
||||
})}
|
||||
open={isV2Ui ? openToolbarMenu === "ai" : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("ai", open) : undefined}
|
||||
>
|
||||
@@ -659,6 +682,13 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
menu={{ items: aiMenuItems }}
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
rootClassName={isV2Ui ? 'gn-v2-action-menu-popup-host' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, isV2Ui, {
|
||||
title: aiMoreTitle,
|
||||
meta: menuContextMeta,
|
||||
icon: <RobotOutlined />,
|
||||
badge: 'AI',
|
||||
})}
|
||||
open={isV2Ui ? openToolbarMenu === "ai" : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("ai", open) : undefined}
|
||||
>
|
||||
@@ -683,6 +713,13 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
menu={{ items: moreMenuItems }}
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
rootClassName={isV2Ui ? 'gn-v2-action-menu-popup-host' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, isV2Ui, {
|
||||
title: t('query_editor.action.more'),
|
||||
meta: menuContextMeta,
|
||||
icon: <EllipsisOutlined />,
|
||||
badge: t('tab_manager.kind_badge.query'),
|
||||
})}
|
||||
open={isV2Ui ? openToolbarMenu === "more" : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("more", open) : undefined}
|
||||
>
|
||||
@@ -766,6 +803,13 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
|
||||
menu={{ items: formatSettingsMenu }}
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
rootClassName={isV2Ui ? 'gn-v2-action-menu-popup-host' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, isV2Ui, {
|
||||
title: formatSettingsTitle,
|
||||
meta: menuContextMeta,
|
||||
icon: <FormatPainterOutlined />,
|
||||
badge: t('tab_manager.kind_badge.query'),
|
||||
})}
|
||||
open={isV2Ui ? openToolbarMenu === "format" : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("format", open) : undefined}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import RedisViewer from './RedisViewer';
|
||||
|
||||
const appCss = readFileSync(new URL('../App.css', import.meta.url), 'utf8');
|
||||
const redisViewerSource = readFileSync(new URL('./RedisViewer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
@@ -204,6 +205,18 @@ const findFirstLeafNode = (nodes: any[]): any | null => {
|
||||
};
|
||||
|
||||
describe('RedisViewer tree interactions', () => {
|
||||
it('uses the shared compact context-menu item class for v2 key actions', () => {
|
||||
const contextMenuStart = redisViewerSource.indexOf('{treeContextMenu && typeof document');
|
||||
const contextMenuSource = redisViewerSource.slice(
|
||||
contextMenuStart,
|
||||
redisViewerSource.indexOf('), document.body)}', contextMenuStart),
|
||||
);
|
||||
|
||||
expect(contextMenuSource).toContain("'gn-v2-context-menu-item'");
|
||||
expect(contextMenuSource).toContain('style={isV2Ui ? undefined : {');
|
||||
expect(contextMenuSource).not.toContain("style={{ width: '100%', justifyContent: 'flex-start', height: 40");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
antdState.treeProps = null;
|
||||
|
||||
@@ -2841,7 +2841,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
|
||||
className={isV2Ui ? 'gn-v2-context-menu-item' : undefined}
|
||||
style={isV2Ui ? undefined : { width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openRenameKeyModal(treeContextMenu.rawKey)}
|
||||
>
|
||||
@@ -2849,7 +2850,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
|
||||
className={isV2Ui ? 'gn-v2-context-menu-item' : undefined}
|
||||
style={isV2Ui ? undefined : { width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Dropdown, message, Tabs, Tooltip } from 'antd';
|
||||
import { CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, EditOutlined, FileTextOutlined, FolderOpenOutlined, HistoryOutlined, PlusOutlined, PushpinOutlined, RightOutlined, RobotOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined, CloseCircleOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, EditOutlined, ExportOutlined, FileTextOutlined, FolderOpenOutlined, HistoryOutlined, PlusOutlined, PushpinOutlined, RightOutlined, RobotOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps, TabsProps } from 'antd';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
@@ -57,6 +57,7 @@ import { QUERY_TAB_RENAME_REQUEST_EVENT } from '../utils/queryTabTitle';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
|
||||
import { dispatchSidebarLocateConnection } from '../utils/sidebarLocate';
|
||||
import { renderV2ActionMenuPopup } from './common/V2ActionMenuPopup';
|
||||
|
||||
const getTabKindLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'query') return t('tab_manager.kind_badge.query');
|
||||
@@ -632,6 +633,12 @@ const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
trigger={['contextMenu']}
|
||||
onOpenChange={handleTabMenuOpenChange}
|
||||
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, Boolean(isV2Ui), {
|
||||
title: displayTitle,
|
||||
meta: displayModel.secondaryText || getTabKindLabel(tab),
|
||||
icon: tab.type === 'query' ? <ConsoleSqlOutlined /> : <FileTextOutlined />,
|
||||
badge: getTabKindLabel(tab),
|
||||
})}
|
||||
>
|
||||
{wrappedLabel}
|
||||
</Dropdown>
|
||||
@@ -1337,57 +1344,74 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
|
||||
const renameQueryMenuState = resolveQueryTabRenameMenuState(tab);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
...(renameQueryMenuState.visible ? [{
|
||||
key: 'rename-query',
|
||||
icon: <EditOutlined />,
|
||||
label: t('query_editor.action.rename_query'),
|
||||
disabled: renameQueryMenuState.disabled,
|
||||
onClick: () => {
|
||||
setActiveTab(tab.id);
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent(QUERY_TAB_RENAME_REQUEST_EVENT, {
|
||||
detail: { tabId: tab.id },
|
||||
}));
|
||||
}, 0);
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'tab-display-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: t('tab_manager.menu.tab_display_settings'),
|
||||
onClick: openTabDisplaySettings,
|
||||
type: 'group',
|
||||
key: 'tab-actions',
|
||||
label: t('tab_manager.kind_badge.fallback'),
|
||||
children: [
|
||||
...(renameQueryMenuState.visible ? [{
|
||||
key: 'rename-query',
|
||||
icon: <EditOutlined />,
|
||||
label: t('query_editor.action.rename_query'),
|
||||
disabled: renameQueryMenuState.disabled,
|
||||
onClick: () => {
|
||||
setActiveTab(tab.id);
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent(QUERY_TAB_RENAME_REQUEST_EVENT, {
|
||||
detail: { tabId: tab.id },
|
||||
}));
|
||||
}, 0);
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'tab-display-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: t('tab_manager.menu.tab_display_settings'),
|
||||
onClick: openTabDisplaySettings,
|
||||
},
|
||||
{
|
||||
key: 'open-in-window',
|
||||
icon: <ExportOutlined />,
|
||||
label: t('tab_manager.menu.open_in_window'),
|
||||
disabled: isBackgroundTaskWorkbenchTab(tab),
|
||||
onClick: () => detachTabToWindow(tab.id),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'open-in-window',
|
||||
label: t('tab_manager.menu.open_in_window'),
|
||||
disabled: isBackgroundTaskWorkbenchTab(tab),
|
||||
onClick: () => detachTabToWindow(tab.id),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'close-other',
|
||||
label: t('tab_manager.menu.close_other'),
|
||||
disabled: tabs.length <= 1,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id)),
|
||||
},
|
||||
{
|
||||
key: 'close-left',
|
||||
label: t('tab_manager.menu.close_left'),
|
||||
disabled: index === 0,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(dockedTabs, tab.id), () => closeTabsToLeft(tab.id)),
|
||||
},
|
||||
{
|
||||
key: 'close-right',
|
||||
label: t('tab_manager.menu.close_right'),
|
||||
disabled: index === dockedTabs.length - 1,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(dockedTabs, tab.id), () => closeTabsToRight(tab.id)),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'close-all',
|
||||
label: t('tab_manager.menu.close_all'),
|
||||
disabled: tabs.length === 0,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs()),
|
||||
type: 'group',
|
||||
key: 'close-actions',
|
||||
label: t('common.close'),
|
||||
children: [
|
||||
{
|
||||
key: 'close-other',
|
||||
icon: <CloseCircleOutlined />,
|
||||
label: t('tab_manager.menu.close_other'),
|
||||
disabled: tabs.length <= 1,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id)),
|
||||
},
|
||||
{
|
||||
key: 'close-left',
|
||||
icon: <ArrowLeftOutlined />,
|
||||
label: t('tab_manager.menu.close_left'),
|
||||
disabled: index === 0,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(dockedTabs, tab.id), () => closeTabsToLeft(tab.id)),
|
||||
},
|
||||
{
|
||||
key: 'close-right',
|
||||
icon: <ArrowRightOutlined />,
|
||||
label: t('tab_manager.menu.close_right'),
|
||||
disabled: index === dockedTabs.length - 1,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(dockedTabs, tab.id), () => closeTabsToRight(tab.id)),
|
||||
},
|
||||
{
|
||||
key: 'close-all',
|
||||
icon: <CloseOutlined />,
|
||||
label: t('tab_manager.menu.close_all'),
|
||||
disabled: tabs.length === 0,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs()),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Dropdown, Tooltip } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import { renderV2ActionMenuPopup } from './common/V2ActionMenuPopup';
|
||||
|
||||
export interface TitleBarQuickAction {
|
||||
key: string;
|
||||
@@ -65,7 +66,12 @@ const TitleBarQuickActions: React.FC<TitleBarQuickActionsProps> = ({ label, more
|
||||
menu={{ items: buildMenuItems(action.menu), className: 'gn-v2-titlebar-quick-menu' }}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
rootClassName="gn-v2-titlebar-quick-dropdown"
|
||||
rootClassName="gn-v2-titlebar-quick-dropdown gn-v2-action-menu-popup-host"
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, true, {
|
||||
title: action.label,
|
||||
meta: label,
|
||||
icon: action.icon,
|
||||
})}
|
||||
open={openMenuKey === action.key}
|
||||
onOpenChange={(open) => handleMenuOpenChange(action.key, open)}
|
||||
>
|
||||
@@ -108,7 +114,12 @@ const TitleBarQuickActions: React.FC<TitleBarQuickActionsProps> = ({ label, more
|
||||
menu={dropdownMenuProps}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
rootClassName="gn-v2-titlebar-quick-dropdown"
|
||||
rootClassName="gn-v2-titlebar-quick-dropdown gn-v2-action-menu-popup-host"
|
||||
popupRender={(menu) => renderV2ActionMenuPopup(menu, true, {
|
||||
title: moreLabel,
|
||||
meta: label,
|
||||
icon: <MoreOutlined />,
|
||||
})}
|
||||
open={openMenuKey === '__more__'}
|
||||
onOpenChange={(open) => handleMenuOpenChange('__more__', open)}
|
||||
>
|
||||
|
||||
173
frontend/src/components/common/V2ActionMenuPopup.tsx
Normal file
173
frontend/src/components/common/V2ActionMenuPopup.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface V2ActionMenuPopupProps {
|
||||
title: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const V2ActionMenuPopup: React.FC<V2ActionMenuPopupProps> = ({
|
||||
title,
|
||||
meta,
|
||||
icon,
|
||||
badge,
|
||||
children,
|
||||
}) => (
|
||||
<div className="gn-v2-action-menu-surface">
|
||||
<div className="gn-v2-context-menu-header gn-v2-action-menu-header">
|
||||
<span className="gn-v2-context-menu-table-icon gn-v2-action-menu-header-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="gn-v2-context-menu-heading">
|
||||
<strong>{title}</strong>
|
||||
{meta ? <small>{meta}</small> : null}
|
||||
</span>
|
||||
{badge ? <span className="gn-v2-context-menu-engine-pill">{badge}</span> : null}
|
||||
</div>
|
||||
<div className="gn-v2-action-menu-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const renderV2ActionMenuPopup = (
|
||||
menu: React.ReactNode,
|
||||
enabled: boolean,
|
||||
props: Omit<V2ActionMenuPopupProps, 'children'>,
|
||||
): React.ReactNode => (
|
||||
enabled ? <V2ActionMenuPopup {...props}>{menu}</V2ActionMenuPopup> : menu
|
||||
);
|
||||
|
||||
const MONACO_CONTEXT_MENU_STYLE_ID = 'gn-v2-monaco-context-menu-styles';
|
||||
const MONACO_CONTEXT_MENU_STYLES = `
|
||||
.monaco-menu {
|
||||
min-width: 264px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 4px !important;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: var(--gn-v2-menu-surface-radius, 10px) !important;
|
||||
background: var(--gn-bg-panel) !important;
|
||||
color: var(--gn-fg-1) !important;
|
||||
box-shadow: var(--gn-shadow-lg) !important;
|
||||
}
|
||||
.monaco-menu > .gn-v2-monaco-context-menu-header {
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(0, 1fr);
|
||||
column-gap: 6px;
|
||||
row-gap: 3px;
|
||||
align-items: center;
|
||||
margin: -4px -4px 4px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 0.5px solid var(--gn-br-1);
|
||||
border-radius: 9px 9px 0 0;
|
||||
background: var(--gn-bg-panel-2);
|
||||
font-family: var(--gn-font-sans);
|
||||
}
|
||||
.gn-v2-monaco-context-menu-header .gn-v2-action-menu-header-icon {
|
||||
width: 16px;
|
||||
color: var(--gn-info);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.gn-v2-monaco-context-menu-header .gn-v2-context-menu-heading { display: contents; }
|
||||
.gn-v2-monaco-context-menu-header strong {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--gn-fg-1);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gn-v2-monaco-context-menu-header small {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
color: var(--gn-fg-4);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.monaco-menu .monaco-action-bar.vertical .action-menu-item,
|
||||
.monaco-menu .monaco-action-bar.vertical .action-label:not(.separator) {
|
||||
min-height: var(--gn-v2-menu-row-height, 28px) !important;
|
||||
border-radius: var(--gn-v2-menu-item-radius, 5px) !important;
|
||||
font-size: 12.5px !important;
|
||||
line-height: var(--gn-v2-menu-row-height, 28px) !important;
|
||||
}
|
||||
.monaco-menu .monaco-action-bar.vertical .action-label:not(.separator) {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.monaco-menu .monaco-action-bar.vertical .action-menu-item:focus {
|
||||
background: var(--gn-bg-active) !important;
|
||||
color: var(--gn-fg-1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.monaco-menu .monaco-action-bar .action-item .action-label.separator {
|
||||
width: auto !important;
|
||||
height: 1px !important;
|
||||
min-height: 1px !important;
|
||||
margin: 4px !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
background: var(--gn-br-1) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const decorateV2MonacoContextMenu = (title: string, meta?: string): void => {
|
||||
const roots: Array<Document | ShadowRoot> = [document];
|
||||
document.querySelectorAll<HTMLElement>('*').forEach((element) => {
|
||||
if (element.shadowRoot) roots.push(element.shadowRoot);
|
||||
});
|
||||
|
||||
const menus = roots.flatMap((root) => {
|
||||
if (root instanceof ShadowRoot && !root.getElementById(MONACO_CONTEXT_MENU_STYLE_ID)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = MONACO_CONTEXT_MENU_STYLE_ID;
|
||||
style.textContent = MONACO_CONTEXT_MENU_STYLES;
|
||||
root.append(style);
|
||||
}
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('.monaco-menu'));
|
||||
}).filter((menu) => menu.getClientRects().length > 0);
|
||||
const menu = menus[menus.length - 1];
|
||||
if (!menu) return;
|
||||
|
||||
const existing = menu.querySelector<HTMLElement>(':scope > .gn-v2-monaco-context-menu-header');
|
||||
if (existing) {
|
||||
const titleElement = existing.querySelector<HTMLElement>('.gn-v2-context-menu-heading strong');
|
||||
if (titleElement) titleElement.textContent = title;
|
||||
const metaElement = existing.querySelector<HTMLElement>('.gn-v2-context-menu-heading small');
|
||||
if (metaElement) metaElement.textContent = meta || '';
|
||||
return;
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'gn-v2-context-menu-header gn-v2-action-menu-header gn-v2-monaco-context-menu-header';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'gn-v2-context-menu-table-icon gn-v2-action-menu-header-icon gn-v2-monaco-context-menu-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = 'SQL';
|
||||
|
||||
const heading = document.createElement('span');
|
||||
heading.className = 'gn-v2-context-menu-heading';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = title;
|
||||
heading.appendChild(strong);
|
||||
if (meta) {
|
||||
const small = document.createElement('small');
|
||||
small.textContent = meta;
|
||||
heading.appendChild(small);
|
||||
}
|
||||
|
||||
header.append(icon, heading);
|
||||
menu.prepend(header);
|
||||
};
|
||||
|
||||
export default V2ActionMenuPopup;
|
||||
@@ -989,7 +989,7 @@ body[data-ui-version="v2"] .gn-v2-redis-data-section .ant-table-wrapper {
|
||||
body[data-ui-version="v2"] .gn-v2-redis-context-menu {
|
||||
width: 208px !important;
|
||||
min-width: 188px !important;
|
||||
padding: 6px !important;
|
||||
padding: var(--gn-v2-menu-surface-padding) !important;
|
||||
}
|
||||
|
||||
/* ─── V2 Nacos workbench (flat split + aligned headers, match Redis) ─ */
|
||||
|
||||
@@ -130,6 +130,10 @@ body[data-ui-version="v2"]:not([data-custom-theme]) {
|
||||
|
||||
/* ─── Base typography & body ──────────────────────────── */
|
||||
body[data-ui-version="v2"] {
|
||||
--gn-v2-menu-row-height: 28px;
|
||||
--gn-v2-menu-item-radius: 5px;
|
||||
--gn-v2-menu-surface-radius: 10px;
|
||||
--gn-v2-menu-surface-padding: 4px;
|
||||
font-family: var(--gn-font-sans) !important;
|
||||
font-size: var(--gn-font-size, var(--gonavi-font-size, 14px));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -429,9 +433,9 @@ body[data-ui-version="v2"] .ant-dropdown-menu,
|
||||
body[data-ui-version="v2"] .ant-picker-dropdown .ant-picker-panel-container {
|
||||
background: var(--gn-bg-panel) !important;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 10px !important;
|
||||
border-radius: var(--gn-v2-menu-surface-radius) !important;
|
||||
box-shadow: var(--gn-shadow-lg) !important;
|
||||
padding: 4px !important;
|
||||
padding: var(--gn-v2-menu-surface-padding) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .ant-dropdown-menu-item,
|
||||
@@ -452,6 +456,17 @@ body[data-ui-version="v2"] .ant-select-item {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Compact action menus share the table context-menu geometry. */
|
||||
body[data-ui-version="v2"] .ant-dropdown-menu-item,
|
||||
body[data-ui-version="v2"] .ant-dropdown-menu-submenu-title {
|
||||
height: var(--gn-v2-menu-row-height) !important;
|
||||
min-height: var(--gn-v2-menu-row-height) !important;
|
||||
box-sizing: border-box !important;
|
||||
padding: 4px 8px !important;
|
||||
border-radius: var(--gn-v2-menu-item-radius) !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Icon + label share one baseline/vertical center (leaf and submenu rows) */
|
||||
body[data-ui-version="v2"] .ant-dropdown-menu-title-content {
|
||||
display: inline-flex !important;
|
||||
@@ -501,12 +516,12 @@ body[data-ui-version="v2"] .ant-dropdown-menu-submenu-title .ant-dropdown-menu-i
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
flex: 0 0 16px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
flex: 0 0 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
margin-inline-end: 8px !important;
|
||||
margin-right: 8px !important;
|
||||
font-size: 14px !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
@@ -536,6 +551,63 @@ body[data-ui-version="v2"] .ant-dropdown-menu-item-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.08) !important;
|
||||
}
|
||||
|
||||
/* Monaco context menu follows the same compact action-menu surface. */
|
||||
body[data-ui-version="v2"] .monaco-menu {
|
||||
min-width: 264px !important;
|
||||
overflow: hidden !important;
|
||||
padding: var(--gn-v2-menu-surface-padding) !important;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: var(--gn-v2-menu-surface-radius) !important;
|
||||
background: var(--gn-bg-panel) !important;
|
||||
box-shadow: var(--gn-shadow-lg) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .monaco-menu > .gn-v2-monaco-context-menu-header {
|
||||
margin: calc(var(--gn-v2-menu-surface-padding) * -1) calc(var(--gn-v2-menu-surface-padding) * -1) 4px;
|
||||
border-radius: calc(var(--gn-v2-menu-surface-radius) - 1px) calc(var(--gn-v2-menu-surface-radius) - 1px) 0 0;
|
||||
font-family: var(--gn-font-sans);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-monaco-context-menu-icon {
|
||||
width: 18px;
|
||||
color: var(--gn-info);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .monaco-menu-option,
|
||||
body[data-ui-version="v2"] .monaco-menu .monaco-action-bar.vertical .action-menu-item,
|
||||
body[data-ui-version="v2"] .monaco-menu .monaco-action-bar.vertical .action-label:not(.separator) {
|
||||
min-height: var(--gn-v2-menu-row-height) !important;
|
||||
border-radius: var(--gn-v2-menu-item-radius) !important;
|
||||
font-size: 12.5px !important;
|
||||
line-height: var(--gn-v2-menu-row-height) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .monaco-menu-option,
|
||||
body[data-ui-version="v2"] .monaco-menu .monaco-action-bar.vertical .action-label:not(.separator) {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .monaco-menu-option.active,
|
||||
body[data-ui-version="v2"] .monaco-menu .monaco-action-bar.vertical .action-menu-item:focus {
|
||||
background: var(--gn-bg-active) !important;
|
||||
color: var(--gn-fg-1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .monaco-menu .monaco-action-bar .action-item .action-label.separator {
|
||||
width: auto !important;
|
||||
height: 1px !important;
|
||||
min-height: 1px !important;
|
||||
margin: 4px !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
background: var(--gn-br-1) !important;
|
||||
}
|
||||
|
||||
/* Nested dropdown menus: collapse double border, no glow/halo */
|
||||
body[data-ui-version="v2"] .ant-dropdown-menu-submenu-popup,
|
||||
body[data-ui-version="v2"] .gn-v2-titlebar-quick-submenu,
|
||||
@@ -575,7 +647,7 @@ body[data-ui-version="v2"] .gn-v2-titlebar-quick-menu .ant-dropdown-menu-submenu
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
min-height: 32px !important;
|
||||
min-height: var(--gn-v2-menu-row-height) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-titlebar-quick-dropdown .ant-dropdown-menu-item:hover,
|
||||
@@ -4314,26 +4386,6 @@ body[data-ui-version="v2"] .gn-v2-tab-close:hover {
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu {
|
||||
min-width: 188px;
|
||||
padding: 6px !important;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 12px !important;
|
||||
background: color-mix(in srgb, var(--gn-bg-panel) 94%, transparent) !important;
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(15, 23, 42, 0.04) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item {
|
||||
min-height: 34px !important;
|
||||
padding: 7px 10px !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--gn-fg-2) !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 650 !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item:hover {
|
||||
background: var(--gn-bg-active) !important;
|
||||
color: var(--gn-fg-1) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item-disabled {
|
||||
@@ -4341,30 +4393,11 @@ body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item-icon {
|
||||
color: var(--gn-info);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item-divider {
|
||||
margin: 5px 4px !important;
|
||||
background: var(--gn-br-1) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-title-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item:first-child {
|
||||
color: var(--gn-fg-1) !important;
|
||||
background: color-mix(in srgb, var(--gn-info) 8%, transparent) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-tab-context-menu-popup .ant-dropdown-menu-item:first-child:hover {
|
||||
background: color-mix(in srgb, var(--gn-info) 14%, transparent) !important;
|
||||
}
|
||||
|
||||
/* ─── V2 DataGrid: toolbar, smart filters, table, statusbar ─ */
|
||||
body[data-ui-version="v2"] .gn-v2-data-viewer {
|
||||
background: var(--gn-bg-panel);
|
||||
@@ -5760,11 +5793,85 @@ body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap .data-grid-paginatio
|
||||
}
|
||||
|
||||
/* ─── V2 context menus, including portal-rendered custom menus ─ */
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-surface {
|
||||
width: 264px;
|
||||
max-width: calc(100vw - 24px);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
border: 0.5px solid var(--gn-br-2);
|
||||
border-radius: var(--gn-v2-menu-surface-radius);
|
||||
background: var(--gn-bg-panel);
|
||||
color: var(--gn-fg-1);
|
||||
box-shadow: var(--gn-shadow-lg);
|
||||
font-size: 12.5px;
|
||||
animation: gnV2ContextMenuFadeIn 0.08s ease-out;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-surface .gn-v2-action-menu-header {
|
||||
border-radius: calc(var(--gn-v2-menu-surface-radius) - 1px) calc(var(--gn-v2-menu-surface-radius) - 1px) 0 0;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-header-icon:empty::before {
|
||||
content: "\2022";
|
||||
color: var(--gn-fg-5);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body {
|
||||
overflow: hidden;
|
||||
padding: 4px 4px 8px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body > .ant-dropdown-menu {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-item,
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-submenu-title {
|
||||
padding: 0 8px 0 10px !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-item-group-title {
|
||||
padding: 8px 12px 2px !important;
|
||||
color: var(--gn-fg-5) !important;
|
||||
font-size: 9.5px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.25 !important;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-item-group-list {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-title-content > .gn-v2-context-menu-item-title {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-item:not(:has(.ant-dropdown-menu-item-icon))::before,
|
||||
body[data-ui-version="v2"] .gn-v2-action-menu-body .ant-dropdown-menu-submenu-title:not(:has(.ant-dropdown-menu-item-icon))::before {
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-context-menu {
|
||||
width: 260px !important;
|
||||
min-width: 220px !important;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 10px !important;
|
||||
border-radius: var(--gn-v2-menu-surface-radius) !important;
|
||||
background: var(--gn-bg-panel) !important;
|
||||
box-shadow: var(--gn-shadow-lg) !important;
|
||||
color: var(--gn-fg-1) !important;
|
||||
@@ -5807,7 +5914,7 @@ body[data-ui-version="v2"] .gn-v2-table-context-menu {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0.5px solid var(--gn-br-2);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--gn-v2-menu-surface-radius);
|
||||
background: var(--gn-bg-panel);
|
||||
color: var(--gn-fg-1);
|
||||
box-shadow: var(--gn-shadow-lg);
|
||||
@@ -5926,12 +6033,12 @@ body[data-ui-version="v2"] .gn-v2-context-menu-divider {
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-context-menu-item {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
height: var(--gn-v2-menu-row-height);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--gn-v2-menu-item-radius);
|
||||
padding: 0 8px 0 10px;
|
||||
background: transparent;
|
||||
color: var(--gn-fg-1);
|
||||
|
||||
Reference in New Issue
Block a user