diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 95460e06..6f58ebfe 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -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 - : - ))} - - ), + 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 ( + + {item.label} + {renderMenuItems(item.children)} + + ); + } + return ; + }); + return ( + <> + {children} + {renderMenuItems(menu?.items)} + + ); + }, Tooltip: ({ children }: any) => <>{children}, Select: () => null, Tabs: ({ activeKey, items, onChange, tabBarExtraContent }: any) => { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 520cbe77..531dbd59 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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: ( - - {translate('query_editor.action.save_as')} - {saveQueryAsShortcutBinding?.enabled && saveQueryAsShortcutBinding.combo && ( - - {getShortcutDisplayLabel(saveQueryAsShortcutBinding.combo, activeShortcutPlatform)} - - )} - - ), - 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: ( + + {translate('query_editor.action.save_as')} + {saveQueryAsShortcutBinding?.enabled && saveQueryAsShortcutBinding.combo && ( + + {getShortcutDisplayLabel(saveQueryAsShortcutBinding.combo, activeShortcutPlatform)} + + )} + + ), + 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: ( - - {translate('app.shortcuts.action.diagnoseQuery.label' as any)} - {diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && ( - - {getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)} + type: 'group', + key: 'analysis-actions', + label: translate('tab_manager.kind_badge.sql_analysis'), + children: [ + { + key: 'diagnose-query', + label: ( + + {translate('app.shortcuts.action.diagnoseQuery.label' as any)} + {diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && ( + + {getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)} + + )} - )} - - ), - disabled: !currentConnectionCapabilities.supportsExplainDiagnosis, - onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()), - }, - { - key: 'show-slow-queries', - label: ( - - {translate('app.shortcuts.action.showSlowQueries.label' as any)} - {showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && ( - - {getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)} + ), + disabled: !currentConnectionCapabilities.supportsExplainDiagnosis, + onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()), + }, + { + key: 'show-slow-queries', + label: ( + + {translate('app.shortcuts.action.showSlowQueries.label' as any)} + {showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && ( + + {getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)} + + )} - )} - - ), - onClick: () => openSqlAnalysisWorkbench('slow-query'), + ), + onClick: () => openSqlAnalysisWorkbench('slow-query'), + }, + ], }, ]; diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 98453c3d..5264c86a 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -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>; @@ -496,40 +497,67 @@ const QueryEditorResultsPanel: React.FC = ({ 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: , + 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: , + label: t('query_editor.results_panel.menu.open_in_window'), + onClick: () => onOpenResultInWindow(key), + }] + : []), + ...(onCompareResult + ? [{ + key: 'compare-results', + icon: , + 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: , label: t('query_editor.results_panel.menu.close_other'), disabled: !hasClosableOtherResult, onClick: () => onCloseOtherResultTabs(key) }, + { key: 'close-left', icon: , label: t('query_editor.results_panel.menu.close_left'), disabled: !hasClosableResultToLeft, onClick: () => onCloseResultTabsToLeft(key) }, + { key: 'close-right', icon: , label: t('query_editor.results_panel.menu.close_right'), disabled: !hasClosableResultToRight, onClick: () => onCloseResultTabsToRight(key) }, + { key: 'close-all', icon: , 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: ( - + 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: , + badge: currentDb || undefined, + })} + >
{ 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 {'); + }); }); diff --git a/frontend/src/components/QueryEditorToolbar.tsx b/frontend/src/components/QueryEditorToolbar.tsx index 8e300312..0e16170e 100644 --- a/frontend/src/components/QueryEditorToolbar.tsx +++ b/frontend/src/components/QueryEditorToolbar.tsx @@ -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 = ({ ? 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 = ({ const moreMenuItems: MenuProps["items"] = isV2Ui ? [ ...baseMoreMenuItems, - ...(baseMoreMenuItems.length > 0 ? [{ type: "divider" as const }] : []), { - key: "toggle-result-panel", - label: toggleResultPanelTitle, - icon: isResultPanelVisible ? ( - - ) : ( - - ), - onClick: onToggleResultPanelVisibility, + type: 'group', + key: 'result-visibility', + label: t('query_editor.action.results'), + children: [{ + key: "toggle-result-panel", + label: toggleResultPanelTitle, + icon: isResultPanelVisible ? ( + + ) : ( + + ), + onClick: onToggleResultPanelVisibility, + }], }, ] : baseMoreMenuItems; @@ -437,11 +447,17 @@ const QueryEditorToolbar: React.FC = ({ open={isV2Ui && openToolbarMenu === "templates" ? false : undefined} > - renderV2ActionMenuPopup(menu, isV2Ui, { + title: t('query_editor.elasticsearch.action.templates'), + meta: menuContextMeta, + icon: , + })} + open={isV2Ui ? openToolbarMenu === "templates" : undefined} onOpenChange={isV2Ui ? (open) => updateToolbarMenuOpen("templates", open) : undefined} >