From 541de6fd68a5072011918d1d38807bc2311ef384 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Mon, 7 Sep 2026 15:56:19 +0530 Subject: [PATCH] Close the IDE with Mod+E while Monaco has focus Monaco owns Cmd+E for "Use Selection for Find", so the keydown never reached the application's keyboard system and the shortcut that opened the IDE could not close it again. addKeyboardShortcutOverrides already re-dispatches Cmd/Ctrl+Enter, Cmd/Ctrl+Shift+Enter and Cmd/Ctrl+K on the window; Cmd/Ctrl+E now joins them. Cmd/Ctrl+F is left to Monaco, so the find widget is still a keystroke away. FullScreenIDE registered Escape and Mod+E on one handler that blurs Monaco when the editor has focus and only closes otherwise, so even a delivered Cmd+E would have blurred. The handler is split: Escape keeps stepping out of the editor before it closes, and Mod+E always closes. Closes #4959 --- CHANGELOG.md | 4 + .../components/ide/FullScreenIDE.tsx | 22 +++- assets/js/monaco/keyboard-overrides.ts | 16 +++ .../ide/FullScreenIDE.keyboard.test.tsx | 52 +++++++++ assets/test/monaco/keyboard-overrides.test.ts | 103 ++++++++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 assets/test/monaco/keyboard-overrides.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3aaf57bda..4ce587e543e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ and this project adheres to ### Fixed +- Cmd/Ctrl+E now closes the IDE while the code editor has focus. Monaco claimed + the combination for "Use Selection for Find", so the shortcut that opened the + IDE could not close it again. Cmd/Ctrl+F still opens Monaco's find widget. + [#4959](https://github.com/OpenFn/lightning/issues/4959) - The global assistant no longer offers to paste a reply's code block into whichever job you have open. It applies its own changes and shows them as diffs, so those blocks are data it quoted back or work it has already done. diff --git a/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx b/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx index f3eb4db900e..faeea3d7825 100644 --- a/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx +++ b/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx @@ -701,8 +701,12 @@ export function FullScreenIDE({ }); }, [onCredentialSaved, currentJob, updateJob, requestCredentials]); + const isShortcutEnabled = + !isConfigureModalOpen && !isAdaptorPickerOpen && !isCredentialModalOpen; + + // Escape steps out of the editor before it closes the IDE. useKeyboardShortcut( - 'Escape, Control+e, Meta+e', + 'Escape', () => { const activeElement = document.activeElement; const isMonacoFocused = activeElement?.closest('.monaco-editor'); @@ -714,10 +718,18 @@ export function FullScreenIDE({ } }, 50, // IDE priority - { - enabled: - !isConfigureModalOpen && !isAdaptorPickerOpen && !isCredentialModalOpen, - } + { enabled: isShortcutEnabled } + ); + + // Mod+E always closes the IDE, including from inside Monaco, so that it + // mirrors the Mod+E that opened it. + useKeyboardShortcut( + 'Control+e, Meta+e', + () => { + onClose(); + }, + 50, // IDE priority + { enabled: isShortcutEnabled } ); // Save docs panel collapsed state to localStorage diff --git a/assets/js/monaco/keyboard-overrides.ts b/assets/js/monaco/keyboard-overrides.ts index 4faaac312f1..53a0ebacd07 100644 --- a/assets/js/monaco/keyboard-overrides.ts +++ b/assets/js/monaco/keyboard-overrides.ts @@ -15,6 +15,7 @@ import type { Monaco } from './index'; * - Cmd/Ctrl+Enter: Dispatches to window for run/retry actions * - Cmd/Ctrl+Shift+Enter: Dispatches to window for force-run actions * - Cmd/Ctrl+K: Dispatches to window for AI chat shortcut + * - Cmd/Ctrl+E: Dispatches to window for the IDE open/close shortcut * * Usage: * ```typescript @@ -77,4 +78,19 @@ export function addKeyboardShortcutOverrides( }); window.dispatchEvent(event); }); + + // Override Monaco's Cmd/Ctrl+E ("Use Selection for Find") so the IDE toggle + // still works while the editor has focus. Cmd/Ctrl+F is left alone, so the + // find widget remains reachable. + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyE, () => { + const event = new KeyboardEvent('keydown', { + key: 'e', + code: 'KeyE', + metaKey: isMac, + ctrlKey: !isMac, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + }); } diff --git a/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx b/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx index 73e79cd6c36..0a7ebcaaca2 100644 --- a/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx +++ b/assets/test/collaborative-editor/components/ide/FullScreenIDE.keyboard.test.tsx @@ -3,6 +3,7 @@ * * Tests keyboard shortcuts for the FullScreenIDE component: * - Escape: Smart behavior (blur Monaco first, then close IDE) + * - Mod+E: Close the IDE, including while Monaco has focus * - Mod+Enter: Run or retry (prioritizes retry when available) * - Mod+Shift+Enter: Force new run (ignores retry) * @@ -595,6 +596,57 @@ describe('FullScreenIDE Keyboard Shortcuts', () => { }); }); + describe('Mod+E - Close IDE', () => { + test('closes IDE when Monaco is not focused (Mac)', async () => { + const user = userEvent.setup(); + setupMockUseRunRetry(); + const onClose = vi.fn(); + renderFullScreenIDE({ onClose }); + + await waitFor(() => + expect(screen.getByTestId('collaborative-monaco')).toBeInTheDocument() + ); + + await user.keyboard('{Meta>}e{/Meta}'); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + test('closes IDE while Monaco has focus (Mac)', async () => { + const user = userEvent.setup(); + setupMockUseRunRetry(); + const onClose = vi.fn(); + renderFullScreenIDE({ onClose }); + + await waitFor(() => + expect(screen.getByTestId('collaborative-monaco')).toBeInTheDocument() + ); + + focusElement(screen.getByTestId('monaco-contenteditable')); + + await user.keyboard('{Meta>}e{/Meta}'); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + test('closes IDE while Monaco has focus (Windows)', async () => { + const user = userEvent.setup(); + setupMockUseRunRetry(); + const onClose = vi.fn(); + renderFullScreenIDE({ onClose }); + + await waitFor(() => + expect(screen.getByTestId('collaborative-monaco')).toBeInTheDocument() + ); + + focusElement(screen.getByTestId('monaco-contenteditable')); + + await user.keyboard('{Control>}e{/Control}'); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + }); + describe('Mod+Enter - Run or Retry', () => { // Note: Run shortcuts only work in create-run or run-viewer states test('calls handleRun when in create-run state (Mac)', async () => { diff --git a/assets/test/monaco/keyboard-overrides.test.ts b/assets/test/monaco/keyboard-overrides.test.ts new file mode 100644 index 00000000000..f6e30291bc7 --- /dev/null +++ b/assets/test/monaco/keyboard-overrides.test.ts @@ -0,0 +1,103 @@ +/** + * Monaco keyboard override tests + * + * Monaco claims a handful of Cmd/Ctrl combos for its own commands. These + * overrides re-dispatch them on `window` so the application's keyboard system + * can handle them instead. + */ + +import type { editor } from 'monaco-editor'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { Monaco } from '../../js/monaco'; +import { addKeyboardShortcutOverrides } from '../../js/monaco/keyboard-overrides'; + +// Arbitrary but distinct values; only their combination matters here. +const KeyMod = { CtrlCmd: 2048, Shift: 1024 } as const; +const KeyCode = { Enter: 3, KeyE: 35, KeyF: 36, KeyK: 41 } as const; + +function setupEditor() { + const commands = new Map void>(); + + const editorStub = { + addCommand: (keybinding: number, handler: () => void) => { + commands.set(keybinding, handler); + }, + } as unknown as editor.IStandaloneCodeEditor; + + const monacoStub = { KeyMod, KeyCode } as unknown as Monaco; + + addKeyboardShortcutOverrides(editorStub, monacoStub); + + return commands; +} + +function setUserAgent(userAgent: string) { + Object.defineProperty(navigator, 'userAgent', { + value: userAgent, + configurable: true, + }); +} + +const originalUserAgent = navigator.userAgent; + +describe('addKeyboardShortcutOverrides', () => { + let dispatched: KeyboardEvent[]; + let listener: (event: Event) => void; + + beforeEach(() => { + dispatched = []; + listener = event => { + dispatched.push(event as KeyboardEvent); + }; + window.addEventListener('keydown', listener); + }); + + afterEach(() => { + window.removeEventListener('keydown', listener); + setUserAgent(originalUserAgent); + vi.restoreAllMocks(); + }); + + test('registers an override for Mod+E', () => { + const commands = setupEditor(); + + expect(commands.has(KeyMod.CtrlCmd | KeyCode.KeyE)).toBe(true); + }); + + test('Mod+E dispatches a Cmd+E keydown on Mac', () => { + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); + const commands = setupEditor(); + + commands.get(KeyMod.CtrlCmd | KeyCode.KeyE)?.(); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + key: 'e', + code: 'KeyE', + metaKey: true, + ctrlKey: false, + }); + }); + + test('Mod+E dispatches a Ctrl+E keydown off Mac', () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + const commands = setupEditor(); + + commands.get(KeyMod.CtrlCmd | KeyCode.KeyE)?.(); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + key: 'e', + code: 'KeyE', + metaKey: false, + ctrlKey: true, + }); + }); + + test('leaves Mod+F to Monaco so the find widget still opens', () => { + const commands = setupEditor(); + + expect(commands.has(KeyMod.CtrlCmd | KeyCode.KeyF)).toBe(false); + }); +});