From deebe05ff0377d7fe8eb334c89e367482cc20295 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:28:57 -0700 Subject: [PATCH] fix: open editor rename after context menu releases focus (#18934) * fix: open editor rename after context menu releases focus * refactor(editor): tighten rename focus-handoff comments and test setup Correct the rename-input focus comment that still credited the animation frame with outrunning menu teardown, clarify why the rename now runs from onCloseAutoFocus, and fold the repeated menu-close invocation in the tab tests into one helper. --- .../components/tab-bar/EditorFileTab.test.tsx | 17 +++++++---- .../src/components/tab-bar/EditorFileTab.tsx | 4 +-- .../tab-bar/EditorFileTabContextMenu.test.tsx | 28 +++++++++++++++++-- .../tab-bar/EditorFileTabContextMenu.tsx | 6 ++-- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 6eb614550c9..edc15a53f3c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -345,6 +345,15 @@ function findMenuItemByText(node: unknown, label: string): ReactElementLike { return item } +/** Picks Rename, then fires the close-autofocus that actually opens the input. */ +function selectRenameFromMenu(node: unknown): void { + ;(findMenuItemByText(node, 'Rename').props.onSelect as () => void)() + const content = findElementsByType(node, 'DropdownMenuContent')[0]! + ;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({ + preventDefault: vi.fn() + }) +} + function findSpanByText(node: unknown, label: string): ReactElementLike { const span = findElementsByType(node, 'span').find( (candidate) => @@ -401,7 +410,7 @@ describe('EditorFileTab rename menu', () => { // isUntitled; the tab menu must let users rename the screenshot-style // "untitled-N.md" files directly. expect(renameItem.props.disabled).toBe(false) - ;(renameItem.props.onSelect as () => void)() + selectRenameFromMenu(firstRender) const secondRender = expandNode((await renderEditorFileTab(file, onActivate)).element) const inputs = findElementsByType(secondRender, 'input') @@ -425,9 +434,8 @@ describe('EditorFileTab rename menu', () => { it('ignores IME composition Enter before renaming the editor file tab', async () => { const file = baseFile() const firstRender = expandNode((await renderEditorFileTab(file)).element) - const renameItem = findMenuItemByText(firstRender, 'Rename') - ;(renameItem.props.onSelect as () => void)() + selectRenameFromMenu(firstRender) const secondRender = expandNode((await renderEditorFileTab(file)).element) const input = findElementsByType(secondRender, 'input')[0] @@ -457,9 +465,8 @@ describe('EditorFileTab rename menu', () => { it('does not re-commit when unmounting the rename input emits multiple blur events', async () => { const file = baseFile() const firstRender = expandNode((await renderEditorFileTab(file)).element) - const renameItem = findMenuItemByText(firstRender, 'Rename') - ;(renameItem.props.onSelect as () => void)() + selectRenameFromMenu(firstRender) const secondRender = expandNode((await renderEditorFileTab(file)).element) const input = findElementsByType(secondRender, 'input')[0] diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 78d17b8b929..064ac1fdb0e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -171,8 +171,8 @@ export default function EditorFileTab({ if (!input) { return } - // Why: Radix closes the context menu after onSelect; defer focus so its - // teardown cannot steal focus back or blur-commit the newly mounted input. + // Why: the tab re-lays out around the input; focus on the next frame so + // that swap has settled before selecting text. renameFocusFrameRef.current = requestAnimationFrame(() => { renameFocusFrameRef.current = null if (renameInputRef.current !== input) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 00b2d2a210c..812079563af 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -202,7 +202,9 @@ function extractText(node: unknown): string { return el.props && 'children' in el.props ? extractText(el.props.children) : '' } -async function renderMenu(): Promise { +async function renderMenu( + overrides: { onActivate?: () => void; onOpenRenameInput?: () => void } = {} +): Promise { const module = await import('./EditorFileTabContextMenu') return module.EditorFileTabContextMenu({ open: true, @@ -238,7 +240,8 @@ async function renderMenu(): Promise { onCloseAll: vi.fn(), onCloseToRight: vi.fn(), onCloseToLeft: vi.fn(), - onOpenMarkdownPreview: vi.fn() + onOpenMarkdownPreview: vi.fn(), + ...overrides }) } @@ -266,6 +269,27 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) + it('opens rename only after menu close releases focus and consumes the request once', async () => { + const onActivate = vi.fn() + const onOpenRenameInput = vi.fn() + const tree = expandNode(await renderMenu({ onActivate, onOpenRenameInput })) + const rename = findElementsByType(tree, 'DropdownMenuItem').find((item) => + extractText(item.props.children).includes('Rename') + )! + const content = findElementsByType(tree, 'DropdownMenuContent')[0]! + ;(rename.props.onSelect as () => void)() + expect(onActivate).not.toHaveBeenCalled() + expect(onOpenRenameInput).not.toHaveBeenCalled() + const preventDefault = vi.fn() + const close = content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void + close({ preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(onActivate).toHaveBeenCalledTimes(1) + expect(onOpenRenameInput).toHaveBeenCalledTimes(1) + close({ preventDefault }) + expect(onOpenRenameInput).toHaveBeenCalledTimes(1) + }) + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) const menuItems = findElementsByType(tree, 'DropdownMenuItem') diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 32e29595738..1813265573f 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -126,6 +126,10 @@ export function EditorFileTabContextMenu({ } skipMenuFocusRestoreRef.current = false event.preventDefault() + // Why: opening the input in onSelect lets the still-closing menu reclaim + // focus, and the resulting blur commits the rename away before the user types. + onActivate() + onOpenRenameInput() }} > { skipMenuFocusRestoreRef.current = true - onActivate() - onOpenRenameInput() }} >