diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.test.ts b/frontend/src/components/sidebar/useSidebarV2ContextMenu.test.ts new file mode 100644 index 00000000..12158a69 --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + handleSidebarV2ContextMenuShortcut, + type SidebarContextMenuState, +} from './useSidebarV2ContextMenu'; + +const createKeyEvent = (overrides: Partial = {}): KeyboardEvent => ({ + key: 'c', + code: 'KeyC', + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + keyCode: 67, + which: 67, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides, +} as unknown as KeyboardEvent); + +const createTableMenu = (node: Record): SidebarContextMenuState => ({ + x: 10, + y: 20, + items: [], + kind: 'v2-table', + node, +}); + +describe('V2 sidebar context-menu shortcuts', () => { + it.each([ + ['mac', { metaKey: true }], + ['windows', { ctrlKey: true }], + ] as const)('routes the primary copy shortcut on %s to the current table', (platform, modifiers) => { + const node = { key: `table-${platform}`, title: 'ir_staff' }; + const event = createKeyEvent(modifiers); + const onTableAction = vi.fn(); + const onClose = vi.fn(); + + expect(handleSidebarV2ContextMenuShortcut({ + event, + contextMenu: createTableMenu(node), + shortcutPlatform: platform, + onTableAction, + onClose, + })).toBe(true); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onTableAction).toHaveBeenCalledWith(node, 'copy-table-name'); + }); + + it.each([ + createKeyEvent(), + createKeyEvent({ altKey: true, metaKey: true }), + createKeyEvent({ ctrlKey: true, shiftKey: true }), + ])('ignores copy-like keys that do not match the platform shortcut', (event) => { + const onTableAction = vi.fn(); + const onClose = vi.fn(); + + expect(handleSidebarV2ContextMenuShortcut({ + event, + contextMenu: createTableMenu({ key: 'table-a' }), + shortcutPlatform: 'mac', + onTableAction, + onClose, + })).toBe(false); + + expect(onTableAction).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('uses the latest node when a table menu is replaced without changing kind', () => { + const onTableAction = vi.fn(); + const firstNode = { key: 'table-a' }; + const latestNode = { key: 'table-b' }; + + handleSidebarV2ContextMenuShortcut({ + event: createKeyEvent({ metaKey: true }), + contextMenu: createTableMenu(latestNode), + shortcutPlatform: 'mac', + onTableAction, + onClose: vi.fn(), + }); + + expect(onTableAction).toHaveBeenCalledWith(latestNode, 'copy-table-name'); + expect(onTableAction).not.toHaveBeenCalledWith(firstNode, 'copy-table-name'); + }); +}); diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx index c35c4928..cbf76d20 100644 --- a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx +++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx @@ -29,6 +29,7 @@ import { SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, resolveSidebarContextMenuPosition, } from '../sidebarCoreUtils'; +import { isShortcutMatch, type ShortcutPlatform } from '../../utils/shortcuts'; export type SidebarContextMenuState = { x: number; @@ -46,7 +47,7 @@ export type SidebarContextMenuState = { type SidebarV2ContextMenuOptions = { connections: SavedConnection[]; connectionTags: Array<{ id: string; name: string; connectionIds: string[] }>; - activeShortcutPlatform: any; + activeShortcutPlatform: ShortcutPlatform; flattenConnectionNodes: (nodes: TreeNode[]) => TreeNode[]; v2TreeMetrics: { databaseTableCounts: Map; @@ -73,6 +74,39 @@ type SidebarV2ContextMenuOptions = { handleV2ConnectionGroupContextMenuAction: (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => void; }; +export const handleSidebarV2ContextMenuShortcut = ({ + event, + contextMenu, + shortcutPlatform, + onTableAction, + onClose, +}: { + event: KeyboardEvent; + contextMenu: SidebarContextMenuState | null; + shortcutPlatform: ShortcutPlatform; + onTableAction: (node: any, action: V2TableContextMenuActionKey) => void; + onClose: () => void; +}): boolean => { + if (event.key === 'Escape') { + onClose(); + return true; + } + if (event.defaultPrevented || contextMenu?.kind !== 'v2-table' || !contextMenu.node) { + return false; + } + + const copyShortcut = shortcutPlatform === 'mac' ? 'Meta+C' : 'Ctrl+C'; + if (!isShortcutMatch(event, copyShortcut)) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + onClose(); + onTableAction(contextMenu.node, 'copy-table-name'); + return true; +}; + export const useSidebarV2ContextMenu = ({ connections, connectionTags, @@ -408,15 +442,21 @@ export const useSidebarV2ContextMenu = ({ setContextMenu(null); }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setContextMenu(null); + handleSidebarV2ContextMenuShortcut({ + event, + contextMenu, + shortcutPlatform: activeShortcutPlatform, + onTableAction: handleV2TableContextMenuAction, + onClose: () => setContextMenu(null), + }); }; document.addEventListener('mousedown', onPointerDown); - document.addEventListener('keydown', onKeyDown); + document.addEventListener('keydown', onKeyDown, true); return () => { document.removeEventListener('mousedown', onPointerDown); - document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keydown', onKeyDown, true); }; - }, [contextMenu?.kind]); + }, [activeShortcutPlatform, contextMenu, handleV2TableContextMenuAction]); useEffect(() => { if (!contextMenu?.kind) return;