From b6bd1f8bd609ebb8ba4e5099a41a869eae1dc626 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:06:26 -0700 Subject: [PATCH] fix(renderer): guard remaining IME text-entry handlers --- .../src/components/LinearIssueTextEditor.tsx | 7 ++ .../src/components/LinearIssueWorkspace.tsx | 4 + .../src/components/LinearItemDrawer.tsx | 10 +++ .../src/components/TerminalSearch.test.tsx | 33 ++++++- .../src/components/TerminalSearch.tsx | 4 + .../browser-pane/BrowserFind.test.tsx | 53 +++++++++++ .../components/browser-pane/BrowserFind.tsx | 4 + .../components/browser-pane/BrowserPane.tsx | 13 +++ .../src/components/editor/PdfFind.test.tsx | 49 +++++++++++ .../src/components/editor/PdfFind.tsx | 4 + .../editor/RichMarkdownSearchBar.test.tsx | 74 ++++++++++++++++ .../editor/RichMarkdownSearchBar.tsx | 7 ++ .../editor/UntitledFileRenameDialog.tsx | 7 ++ .../editor/useRichMarkdownSearch.test.tsx | 50 +++++++++++ .../editor/useRichMarkdownSearch.ts | 4 + .../github-project/ProjectPicker.tsx | 4 + .../slug-dialog/SlugDialogBody.tsx | 4 + .../NativeChatQuestionCard.test.tsx | 29 ++++++ .../native-chat/NativeChatQuestionCard.tsx | 4 + .../network/CustomAddressDialog.test.tsx | 28 ++++++ .../network/CustomAddressDialog.tsx | 4 + .../new-workspace/ProjectCombobox.test.tsx | 30 +++++++ .../new-workspace/ProjectCombobox.tsx | 4 + .../components/right-sidebar/ChecksPanel.tsx | 4 + .../right-sidebar/FileExplorerRow.tsx | 4 + .../source-control-header-toolbar.tsx | 4 + .../src/components/settings/AgentsPane.tsx | 10 +++ ...ngsFormControls.font-autocomplete.test.tsx | 52 +++++++++++ .../settings/SettingsFormControls.tsx | 7 ++ .../codex-session-source-home-control.tsx | 4 + .../components/sidebar/HostRenameDialog.tsx | 4 + .../sidebar/RemoteFileBrowser.paste.test.tsx | 69 +++++++++++++++ .../components/sidebar/RemoteFileBrowser.tsx | 4 + .../components/sidebar/WorktreeMetaDialog.tsx | 7 ++ .../src/lib/ime-escape-guard-ordering.test.ts | 88 +++++++++++++++---- 35 files changed, 667 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/BrowserFind.test.tsx create mode 100644 src/renderer/src/components/editor/PdfFind.test.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownSearchBar.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index e00f508a7ac..907a49a2708 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { LinearIssueMarkdownDescriptionEditor } from '@/components/LinearIssueMarkdownDescriptionEditor' import { useMountedRef } from '@/hooks/useMountedRef' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useAppStore } from '@/store' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { linearUpdateIssue } from '@/runtime/runtime-linear-client' @@ -153,6 +154,9 @@ export function LinearIssueTextEditor({ const handleDescriptionKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(event)) { + return + } if (!isScreenSubmitShortcut(event)) { return } @@ -172,6 +176,9 @@ export function LinearIssueTextEditor({ const handleTitleKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() event.currentTarget.blur() diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index ccb1f03c0e5..f7348e4aef9 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -38,6 +38,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { createBrowserUuid } from '@/lib/browser-uuid' import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot' import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { @@ -289,6 +290,9 @@ function LinearIssueSubIssueButton({ value={title} onChange={(event) => setTitle(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() void handleCreate() diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 25ee1ffef16..58cd5e05ed1 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -23,6 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { VisuallyHidden } from 'radix-ui' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { getCommentBodySubmitState, hasBoundedCommentBodyText @@ -637,6 +638,9 @@ export function LinearIssueEditSection({ value={estimateInput} onChange={(event) => setEstimateInput(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() handleEstimateSubmit() @@ -883,6 +887,9 @@ export function LinearIssueEditSection({ value={estimateInput} onChange={(event) => setEstimateInput(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() handleEstimateSubmit() @@ -1134,6 +1141,9 @@ export function LinearIssueCommentFooter({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(e)) { + return + } if (isScreenSubmitShortcut(e)) { e.preventDefault() handleSubmit() diff --git a/src/renderer/src/components/TerminalSearch.test.tsx b/src/renderer/src/components/TerminalSearch.test.tsx index f004bed4f9a..3934d4b3a9c 100644 --- a/src/renderer/src/components/TerminalSearch.test.tsx +++ b/src/renderer/src/components/TerminalSearch.test.tsx @@ -19,11 +19,11 @@ function createSearchAddon(): SearchAddon { } as unknown as SearchAddon } -function renderSearch(searchAddon: SearchAddon): ReturnType { +function renderSearch(searchAddon: SearchAddon, onClose = vi.fn()): ReturnType { return render( @@ -84,3 +84,32 @@ describe('TerminalSearch cleanup', () => { expect(addon.findNext).toHaveBeenCalledWith('') }) }) + +describe('TerminalSearch IME ownership', () => { + it('leaves the search open for a marked Escape and closes it for an ordinary Escape', () => { + const onClose = vi.fn() + const view = renderSearch(createSearchAddon(), onClose) + const input = view.getByPlaceholderText('Search...') + + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true }) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not navigate on a marked Enter and preserves ordinary Enter navigation', async () => { + const addon = createSearchAddon() + const view = renderSearch(addon) + const input = view.getByPlaceholderText('Search...') + fireEvent.change(input, { target: { value: 'needle' } }) + await waitFor(() => expect(addon.findNext).toHaveBeenCalled()) + vi.mocked(addon.findNext).mockClear() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true }) + expect(addon.findNext).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }) + expect(addon.findNext).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/TerminalSearch.tsx b/src/renderer/src/components/TerminalSearch.tsx index ed8bf1b906a..bd5ebf5efcc 100644 --- a/src/renderer/src/components/TerminalSearch.tsx +++ b/src/renderer/src/components/TerminalSearch.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import type { SearchState } from '@/components/terminal-pane/keyboard-handlers' import { translate } from '@/i18n/i18n' import { getFindRequestQuery } from '@/lib/find-query-bounds' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { safeFind } from './terminal-search-safe-find' type TerminalSearchProps = { @@ -113,6 +114,9 @@ export default function TerminalSearch({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation() + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Escape') { onClose() diff --git a/src/renderer/src/components/browser-pane/BrowserFind.test.tsx b/src/renderer/src/components/browser-pane/BrowserFind.test.tsx new file mode 100644 index 00000000000..6560190ca4c --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserFind.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import BrowserFind from './BrowserFind' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +afterEach(cleanup) + +function renderFind() { + const onClose = vi.fn() + const webview = { + addEventListener: vi.fn(), + findInPage: vi.fn(), + removeEventListener: vi.fn(), + stopFindInPage: vi.fn() + } + const view = render( + + ) + return { input: view.getByPlaceholderText('Find in page...'), onClose, view, webview } +} + +describe('BrowserFind IME ownership', () => { + it('leaves find open for a marked Escape and closes it for an ordinary Escape', () => { + const { input, onClose } = renderFind() + + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true }) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not navigate on a marked Enter and preserves ordinary Enter navigation', () => { + const { input, webview } = renderFind() + fireEvent.change(input, { target: { value: 'needle' } }) + webview.findInPage.mockClear() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true }) + expect(webview.findInPage).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }) + expect(webview.findInPage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/browser-pane/BrowserFind.tsx b/src/renderer/src/components/browser-pane/BrowserFind.tsx index 5f8033d5803..254cbbe4271 100644 --- a/src/renderer/src/components/browser-pane/BrowserFind.tsx +++ b/src/renderer/src/components/browser-pane/BrowserFind.tsx @@ -3,6 +3,7 @@ import { ChevronUp, ChevronDown, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { getFindRequestQuery } from '@/lib/find-query-bounds' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' type BrowserFindProps = { isOpen: boolean @@ -127,6 +128,9 @@ export default function BrowserFind({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation() + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Escape') { onClose() diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 60cc98d8d7c..826ec436247 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -11,6 +11,7 @@ import { } from 'react' import { createPortal } from 'react-dom' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { createBrowserUuid } from '@/lib/browser-uuid' import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' @@ -454,6 +455,9 @@ function PendingBrowserAnnotationCard({ className="h-24 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" autoFocus onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() @@ -1298,6 +1302,9 @@ function RemoteBrowserPagePane({ return } const handleKeyDown = (event: KeyboardEvent): void => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Escape') { event.preventDefault() setContextMenu(null) @@ -2236,6 +2243,9 @@ function RemoteBrowserPagePane({ if (isEditableKeyboardTarget(event.target)) { return } + if (isImeCompositionKeyDown(event)) { + return + } const target = runtimeTarget() const pageId = remotePageIdRef.current const operationToken = pageId ? createRemoteOperationToken(pageId) : null @@ -3231,6 +3241,9 @@ function BrowserPagePane({ return } const handleKeyDown = (e: KeyboardEvent): void => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Escape') { e.preventDefault() setContextMenu(null) diff --git a/src/renderer/src/components/editor/PdfFind.test.tsx b/src/renderer/src/components/editor/PdfFind.test.tsx new file mode 100644 index 00000000000..c77c1ab66b3 --- /dev/null +++ b/src/renderer/src/components/editor/PdfFind.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { EventBus } from 'pdfjs-dist/web/pdf_viewer.mjs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import PdfFind from './PdfFind' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +afterEach(cleanup) + +function renderFind() { + const onClose = vi.fn() + const eventBus = { dispatch: vi.fn(), off: vi.fn(), on: vi.fn() } + const view = render( + }} + /> + ) + return { eventBus, input: view.getByPlaceholderText('Find in page...'), onClose } +} + +describe('PdfFind IME ownership', () => { + it('leaves find open for a marked Escape and closes it for an ordinary Escape', () => { + const { input, onClose } = renderFind() + + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true }) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not navigate on a marked Enter and preserves ordinary Enter navigation', () => { + const { eventBus, input } = renderFind() + fireEvent.change(input, { target: { value: 'needle' } }) + eventBus.dispatch.mockClear() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true }) + expect(eventBus.dispatch).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }) + expect(eventBus.dispatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/PdfFind.tsx b/src/renderer/src/components/editor/PdfFind.tsx index 16b42e7effb..844d5924d00 100644 --- a/src/renderer/src/components/editor/PdfFind.tsx +++ b/src/renderer/src/components/editor/PdfFind.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import type { EventBus } from 'pdfjs-dist/web/pdf_viewer.mjs' import { translate } from '@/i18n/i18n' import { getFindRequestQuery } from '@/lib/find-query-bounds' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' type PdfFindProps = { isOpen: boolean @@ -94,6 +95,9 @@ export default function PdfFind({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation() + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Escape') { onClose() } else if (e.key === 'Enter' && e.shiftKey) { diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.test.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.test.tsx new file mode 100644 index 00000000000..c40c391753e --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RichMarkdownSearchBar } from './RichMarkdownSearchBar' + +vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +afterEach(cleanup) + +function renderBar() { + const actions = { + onClose: vi.fn(), + onMoveToMatch: vi.fn(), + onQueryChange: vi.fn(), + onReplaceAll: vi.fn(), + onReplaceCurrent: vi.fn(), + onReplaceQueryChange: vi.fn(), + onToggleMatchCase: vi.fn(), + onToggleReplaceMode: vi.fn(), + onToggleWholeWord: vi.fn() + } + const view = render( + + ) + return { actions, view } +} + +describe('RichMarkdownSearchBar IME ownership', () => { + it('withholds marked search-field keys and preserves ordinary navigation and close', () => { + const { actions, view } = renderBar() + const input = view.getByLabelText('Find in rich markdown editor') + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true }) + expect(actions.onMoveToMatch).not.toHaveBeenCalled() + expect(actions.onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 }) + expect(actions.onMoveToMatch).toHaveBeenCalledWith(1) + expect(actions.onClose).toHaveBeenCalledTimes(1) + }) + + it('withholds marked replace-field keys and preserves ordinary replace and close', () => { + const { actions, view } = renderBar() + const input = view.getByLabelText('Replace in rich markdown editor') + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true }) + expect(actions.onReplaceCurrent).not.toHaveBeenCalled() + expect(actions.onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 }) + expect(actions.onReplaceCurrent).toHaveBeenCalledTimes(1) + expect(actions.onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index 9ea8d17e068..b8c814dde7c 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { translate } from '@/i18n/i18n' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' type RichMarkdownSearchBarProps = { activeMatchIndex: number @@ -111,6 +112,9 @@ export function RichMarkdownSearchBar({ value={query} onChange={(event) => onQueryChange(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter' && event.shiftKey) { event.preventDefault() onMoveToMatch(-1) @@ -247,6 +251,9 @@ export function RichMarkdownSearchBar({ value={replaceQuery} onChange={(event) => onReplaceQueryChange(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() onReplaceCurrent() diff --git a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx index e9e6d9c018c..95920339435 100644 --- a/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx +++ b/src/renderer/src/components/editor/UntitledFileRenameDialog.tsx @@ -11,6 +11,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { getRelativePathInsideRoot } from '@/lib/path' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' @@ -157,6 +158,9 @@ export function UntitledFileRenameDialog({ setError(null) }} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() handleSubmit() @@ -186,6 +190,9 @@ export function UntitledFileRenameDialog({ setError(null) }} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() handleSubmit() diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx b/src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx new file mode 100644 index 00000000000..bd561237864 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useRichMarkdownSearch } from './useRichMarkdownSearch' + +afterEach(() => { + cleanup() + document.body.replaceChildren() +}) + +describe('useRichMarkdownSearch IME ownership', () => { + it('keeps search open for a marked Escape and closes it for an ordinary Escape', () => { + const root = document.createElement('div') + const input = document.createElement('input') + root.appendChild(input) + document.body.appendChild(root) + const { result } = renderHook(() => + useRichMarkdownSearch({ + editor: null, + rootRef: { current: root }, + scrollContainerRef: { current: null } + }) + ) + act(() => { + result.current.openSearch() + ;(result.current.searchState.searchInputRef as { current: HTMLInputElement | null }).current = + input + }) + expect(result.current.searchState.isSearchOpen).toBe(true) + + act(() => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + isComposing: true, + bubbles: true + }) + ) + }) + expect(result.current.searchState.isSearchOpen).toBe(true) + + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(result.current.searchState.isSearchOpen).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.ts b/src/renderer/src/components/editor/useRichMarkdownSearch.ts index 035081665ee..c93ff9a1081 100644 --- a/src/renderer/src/components/editor/useRichMarkdownSearch.ts +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { RefObject } from 'react' import type { Editor } from '@tiptap/react' import { TextSelection } from '@tiptap/pm/state' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { @@ -301,6 +302,9 @@ export function useRichMarkdownSearch({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { + if (isImeCompositionKeyDown(event)) { + return + } const root = rootRef.current if (!root) { return diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 2abf6273e59..cfcbd3f24dd 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -621,6 +622,9 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React ) }} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { void handlePaste() } diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index a8b20151a34..0ab2c060cd6 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -13,6 +13,7 @@ import { LabelsEditor } from './LabelsEditor' import { AssigneesEditor } from './AssigneesEditor' import { CommentsList, NewCommentForm } from './Comments' import { translate } from '@/i18n/i18n' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' export function SlugDialogBody({ projectOrigin, @@ -159,6 +160,9 @@ export function SlugDialogBody({ onChange={(e) => setTitleDraft(e.target.value)} onBlur={() => void commitTitle()} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() void commitTitle() diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index 3e2a8b27716..042f1f52d76 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -137,4 +137,33 @@ describe('NativeChatQuestionCard', () => { expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }]) }) + + it('does not submit a composing free-text answer on Enter', () => { + const onAnswer = vi.fn() + render(tabsOrSpaces, onAnswer) + const input = container.querySelector('input')! + + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setter.call(input, '네') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + isComposing: true, + bubbles: true + }) + ) + }) + expect(onAnswer).not.toHaveBeenCalled() + + act(() => { + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }) + ) + }) + expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: '네' }]) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index d5241e23bdf..30c6134cd8e 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -1,6 +1,7 @@ import { useState, type RefObject } from 'react' import { Check, Pencil, X } from 'lucide-react' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt' @@ -193,6 +194,9 @@ export function NativeChatQuestionCard({ value={otherText[index]} onChange={(e) => setOther(index, e.target.value)} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() confirm(true) diff --git a/src/renderer/src/components/network/CustomAddressDialog.test.tsx b/src/renderer/src/components/network/CustomAddressDialog.test.tsx index 77f2e885173..e537742122c 100644 --- a/src/renderer/src/components/network/CustomAddressDialog.test.tsx +++ b/src/renderer/src/components/network/CustomAddressDialog.test.tsx @@ -18,6 +18,34 @@ const copy = { } describe('CustomAddressDialog', () => { + it('does not submit a composing address on Enter', async () => { + const onConfirm = vi.fn().mockResolvedValue(true) + + render( + (input ? { ok: true, value: input } : { ok: false })} + copy={copy} + inputId="custom-address" + onConfirm={onConfirm} + /> + ) + + const input = screen.getByLabelText('Address') + fireEvent.change(input, { target: { value: '서버.example' } }) + fireEvent.keyDown(input, { + key: 'Enter', + code: 'Enter', + keyCode: 13, + isComposing: true + }) + expect(onConfirm).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 }) + await waitFor(() => expect(onConfirm).toHaveBeenCalledWith('서버.example')) + }) + it('keeps a failed asynchronous confirmation open and does not commit it', async () => { const onOpenChange = vi.fn() const onConfirm = vi.fn().mockResolvedValue(false) diff --git a/src/renderer/src/components/network/CustomAddressDialog.tsx b/src/renderer/src/components/network/CustomAddressDialog.tsx index 4b07f50baa6..bd514705877 100644 --- a/src/renderer/src/components/network/CustomAddressDialog.tsx +++ b/src/renderer/src/components/network/CustomAddressDialog.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Loader2 } from 'lucide-react' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' export type CustomAddressValidator = (input: string) => { ok: true; value: string } | { ok: false } @@ -119,6 +120,9 @@ export function CustomAddressDialog({ setConfirmationFailed(false) }} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() void submit() diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index 3b7931238b3..6a034493fc4 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -313,6 +313,36 @@ describe('ProjectCombobox', () => { expect(onValueSelected).toHaveBeenCalledWith('github:stablyai/noqa') }) + it('leaves candidate navigation to the IME and preserves ordinary Enter', () => { + const onValueChange = vi.fn() + + act(() => { + root.render() + }) + openList() + type('noq') + + act(() => { + field().dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + isComposing: true, + bubbles: true + }) + ) + }) + expect(onValueChange).not.toHaveBeenCalled() + + act(() => { + field().dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }) + ) + }) + expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') + }) + it('arms "Add a new project" when a query matches nothing, so Enter is never a wrong guess', () => { const onAddProject = vi.fn() const onValueChange = vi.fn() diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index c305964ca0e..03b6688dc18 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react' import { ChevronDown, FolderPlus } from 'lucide-react' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import { translate } from '@/i18n/i18n' import { @@ -110,6 +111,9 @@ export default function ProjectCombobox({ const handleKeyDown = useCallback( (event: React.KeyboardEvent): void => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() setOpen(true) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 30fade22496..f39d75ae580 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -24,6 +24,7 @@ import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/githu import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { openHttpLink } from '@/lib/http-link-routing' import { Button } from '@/components/ui/button' import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' @@ -2638,6 +2639,9 @@ export default function ChecksPanel(): React.JSX.Element { const handleTitleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() void handleSaveTitle() diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 1b621454a95..66bdb819062 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -33,6 +33,7 @@ import { ContextMenuTrigger } from '@/components/ui/context-menu' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useAppStore } from '@/store' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { detectLanguage } from '@/lib/language-detect' @@ -219,6 +220,9 @@ export function InlineInputRow({ className="flex-1 min-w-0 bg-transparent text-xs text-foreground outline-none border border-ring rounded-sm px-1" defaultValue={inlineInput.type === 'rename' ? inlineInput.existingName : ''} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() submit(e.currentTarget.value) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx index f6bf15c87b9..e90a4edece0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx @@ -10,6 +10,7 @@ import type { PrimaryAction } from './source-control-primary-action' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome' @@ -248,6 +249,9 @@ export function SourceControlHeaderToolbar({ value={filterQuery} onChange={(event) => onFilterQueryChange(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Escape') { event.preventDefault() collapseFilter() diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 21f50bede40..edf9c693c04 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -18,6 +18,7 @@ import { useAppStore } from '@/store' import { Button } from '../ui/button' import { Input } from '../ui/input' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { AgentAwakeSetting } from './AgentAwakeSetting' import { AgentCacheTimerSection } from './AgentCacheTimerSection' import { AgentRuntimeSetting } from './AgentRuntimeSetting' @@ -302,6 +303,9 @@ function AgentCommandOverrideInput({ onChange={(e) => setCmdDraft(e.target.value)} onBlur={commitCmd} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { commitCmd() e.currentTarget.blur() @@ -357,6 +361,9 @@ function AgentDefaultArgsInput({ onChange={(e) => setArgsDraft(e.target.value)} onBlur={commitArgs} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { commitArgs() e.currentTarget.blur() @@ -428,6 +435,9 @@ function AgentDefaultEnvInput({ }} onBlur={commitEnv} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { commitEnv() e.currentTarget.blur() diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index 4e5e3a8f235..4031e1498a4 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -106,6 +106,58 @@ describe('FontAutocomplete', () => { expectOptionsToBePortaled() }) + it('leaves marked candidate navigation to the IME', async () => { + function Harness(): ReactNode { + const [value, setValue] = useState('Geist') + return ( + + ) + } + + await act(async () => { + root.render() + }) + const input = getInput() + await act(async () => input.focus()) + + await act(async () => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + code: 'ArrowDown', + keyCode: 40, + isComposing: true, + bubbles: true + }) + ) + }) + expect( + document + .querySelector('[role="option"][aria-selected="true"]') + ?.textContent?.trim() + ).toBe('Geist') + + await act(async () => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + code: 'ArrowDown', + keyCode: 40, + bubbles: true + }) + ) + }) + expect( + document + .querySelector('[role="option"][aria-selected="true"]') + ?.textContent?.trim() + ).toBe('JetBrains Mono') + }) + it('shows the full list on focus when the committed font has multiple matching suggestions', async () => { function Harness(): ReactNode { const [value, setValue] = useState('Cascadia Mono') diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 6730b4a49fb..516f2c2bf41 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -18,6 +18,7 @@ import { isSettingsFormOptionQueryTooLarge } from './settings-form-option-filter' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' type SettingsSwitchProps = { @@ -593,6 +594,9 @@ export function NumberField({ onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { commit() } @@ -746,6 +750,9 @@ export function FontAutocomplete({ setOpen(true) }} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Escape') { if (open) { e.preventDefault() diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.tsx index 6a49a7b6042..3bda6063dd1 100644 --- a/src/renderer/src/components/settings/codex-session-source-home-control.tsx +++ b/src/renderer/src/components/settings/codex-session-source-home-control.tsx @@ -6,6 +6,7 @@ import { Button } from '../ui/button' import { Input } from '../ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { translate } from '@/i18n/i18n' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' export type AgentSessionSourceHomeControl = { runtimeLabel: string @@ -136,6 +137,9 @@ export function AgentSessionSourceHomeInput({ onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { commit() e.currentTarget.blur() diff --git a/src/renderer/src/components/sidebar/HostRenameDialog.tsx b/src/renderer/src/components/sidebar/HostRenameDialog.tsx index d9089665f43..2981f53efc6 100644 --- a/src/renderer/src/components/sidebar/HostRenameDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRenameDialog.tsx @@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { ExecutionHostId } from '../../../../shared/execution-host' import { applyHostRename, getHostDisplayLabelOverride } from './host-rename-remove' @@ -78,6 +79,9 @@ export function HostRenameDialog({ placeholder={derivedLabel} onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() submit() diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx index 9d3aa7a3d98..058ff5e9d07 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.paste.test.tsx @@ -162,4 +162,73 @@ describe('RemoteFileBrowser paste-sized input', () => { root.unmount() }) }) + + it('withholds marked Escape while preserving ordinary Escape', async () => { + const onCancel = vi.fn() + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render() + await flushPromises() + }) + const input = container.querySelector('input') + if (!input) { + throw new Error('Remote file browser input was not rendered') + } + browseDir.mockClear() + + await act(async () => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + isComposing: true, + bubbles: true + }) + ) + await flushPromises() + }) + expect(onCancel).not.toHaveBeenCalled() + + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(onCancel).toHaveBeenCalledTimes(1) + + await act(async () => { + root.unmount() + }) + }) + + it('withholds marked Enter while preserving ordinary Enter navigation', async () => { + const { input, root } = await renderRemoteFileBrowser() + await changeInput(input, 'src') + browseDir.mockClear() + + await act(async () => { + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + isComposing: true, + bubbles: true + }) + ) + await flushPromises() + }) + expect(browseDir).not.toHaveBeenCalled() + + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + await flushPromises() + }) + expect(browseDir).toHaveBeenCalledTimes(1) + + await act(async () => { + root.unmount() + }) + }) }) diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx index dc43f2bb8fe..37d0b6fc098 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowser.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, Folder, ArrowUp, LoaderCircle, Home, Search } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { getFileTypeIcon } from '@/lib/file-type-icons' import { decideEnterAction, @@ -478,6 +479,9 @@ export function RemoteFileBrowser({ const handleFilterKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { if (preview) { // Path mode Enter. diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 5163a839ebf..cbd2eb58d12 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -18,6 +18,7 @@ import { } from './worktree-meta-updates' import { useWorktreeIssueLink } from './use-worktree-issue-link' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { ExternalLink, LoaderCircle } from 'lucide-react' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' @@ -158,6 +159,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const handleCommentKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(e)) { + return + } const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey if (isPlainEnter || isScreenSubmitShortcut(e)) { e.preventDefault() @@ -170,6 +174,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const handleIssueKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() handleSave() diff --git a/src/renderer/src/lib/ime-escape-guard-ordering.test.ts b/src/renderer/src/lib/ime-escape-guard-ordering.test.ts index cf6abe9d5bb..eb453779370 100644 --- a/src/renderer/src/lib/ime-escape-guard-ordering.test.ts +++ b/src/renderer/src/lib/ime-escape-guard-ordering.test.ts @@ -3,9 +3,8 @@ import { join, sep } from 'node:path' import { describe, expect, it } from 'vitest' // The marker sweep that found the dropped-`isComposing` adapters is blind to this defect: the -// markers are present and the guard is called, just one branch too late. An Escape that dismisses -// an IME candidate window is marked, so a handler that runs its Escape branch first discards the -// draft, resets the search, or cancels recording on a keystroke the user aimed at the IME. +// markers are present and the guard is called, just one branch too late. A marked Enter or Escape +// can commit a candidate, so application dispatch must wait until composition finishes. // // Ordering is a property of the source, not of any one component's behaviour, so it is pinned // here once rather than re-tested in every composer that happens to handle Escape. @@ -15,22 +14,49 @@ import { describe, expect, it } from 'vitest' const ROOTS = ['src/renderer/src', 'src/main', 'src/shared'] const GUARD = 'isImeCompositionKeyDown' const ESCAPE_COMPARISON = /[!=]==\s*['"]Escape['"]/ -// Ordering alone cannot see a second, wholly unguarded Escape handler added below a guarded one, +const COMPOSITION_DISPATCH_COMPARISON = + /[!=]==\s*['"](?:Enter|Escape)['"]|\[[^\]]*['"](?:Enter|Escape)['"][^\]]*\]\.includes\([^)]*\.key\)/ +// Ordering alone cannot see a second, wholly unguarded handler added below a guarded one, // so files this work reviewed are additionally checked for absence — a guard in one handler no // longer vouches for the rest of the file. -const MUST_GUARD_EVERY_ESCAPE_HANDLER = new Set([ +const MUST_GUARD_EVERY_DISPATCH_HANDLER = new Set([ + 'src/renderer/src/components/LinearIssueTextEditor.tsx', + 'src/renderer/src/components/LinearIssueWorkspace.tsx', + 'src/renderer/src/components/LinearItemDrawer.tsx', + 'src/renderer/src/components/TerminalSearch.tsx', 'src/renderer/src/components/NewWorkspaceComposerModal.tsx', 'src/renderer/src/components/browser-pane/BrowserAddressBar.tsx', + 'src/renderer/src/components/browser-pane/BrowserFind.tsx', + 'src/renderer/src/components/browser-pane/BrowserPane.tsx', 'src/renderer/src/components/diff-comments/DiffCommentPopover.tsx', 'src/renderer/src/components/editor/EditorPanelHeaderPath.tsx', 'src/renderer/src/components/editor/MarkdownPreview.tsx', + 'src/renderer/src/components/editor/PdfFind.tsx', 'src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx', + 'src/renderer/src/components/editor/RichMarkdownSearchBar.tsx', + 'src/renderer/src/components/editor/UntitledFileRenameDialog.tsx', + 'src/renderer/src/components/editor/useRichMarkdownSearch.ts', 'src/renderer/src/components/github/GitHubMarkdownComposer.tsx', + 'src/renderer/src/components/github-project/ProjectPicker.tsx', 'src/renderer/src/components/github-project/ProjectCell.tsx', - 'src/renderer/src/components/mobile/use-mobile-page-escape.ts' + 'src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx', + 'src/renderer/src/components/mobile/use-mobile-page-escape.ts', + 'src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx', + 'src/renderer/src/components/network/CustomAddressDialog.tsx', + 'src/renderer/src/components/new-workspace/ProjectCombobox.tsx', + 'src/renderer/src/components/right-sidebar/ChecksPanel.tsx', + 'src/renderer/src/components/right-sidebar/FileExplorerRow.tsx', + 'src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx', + 'src/renderer/src/components/right-sidebar/useFileSearchPanel.ts', + 'src/renderer/src/components/settings/AgentsPane.tsx', + 'src/renderer/src/components/settings/SettingsFormControls.tsx', + 'src/renderer/src/components/settings/codex-session-source-home-control.tsx', + 'src/renderer/src/components/sidebar/HostRenameDialog.tsx', + 'src/renderer/src/components/sidebar/RemoteFileBrowser.tsx', + 'src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx' ]) const MUST_SCAN = [ - ...MUST_GUARD_EVERY_ESCAPE_HANDLER, + ...MUST_GUARD_EVERY_DISPATCH_HANDLER, 'src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts', 'src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx', 'src/renderer/src/components/settings/ShortcutRecorderButton.tsx', @@ -87,7 +113,8 @@ function bodyBrace(source: string, from: number): number { // Returns every function body in `source`, including inline JSX keydown handlers. function functionBodies(source: string): string[] { const bodies: string[] = [] - const starts = /(?:on|handle)KeyDown(?:Capture)?\s*[:=]|(?:function|const)\s+\w+\s*[=(]/g + const starts = + /(?:on|handle)KeyDown(?:Capture)?\s*[:=]|(?:function|const)\s+\w+\s*[=(]|addEventListener\(\s*['"]keydown['"]\s*,\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*=>/g for (let match = starts.exec(source); match !== null; match = starts.exec(source)) { const open = bodyBrace(source, match.index + match[0].length) if (open === -1) { @@ -109,33 +136,58 @@ function functionBodies(source: string): string[] { return bodies } -describe('composition guards sit above the Escape branch', () => { +function bodyDispatchesBeforeGuard(body: string, requiresGuard: boolean): boolean { + const guardAt = body.indexOf(GUARD) + const dispatchAt = body.search( + requiresGuard ? COMPOSITION_DISPATCH_COMPARISON : ESCAPE_COMPARISON + ) + return ( + dispatchAt !== -1 && + ((requiresGuard && guardAt === -1) || (guardAt !== -1 && dispatchAt < guardAt)) + ) +} + +describe('composition guards sit above Enter and Escape dispatch', () => { const offenders = new Set() for (const root of ROOTS) { for (const file of sourceFiles(root)) { const source = readFileSync(file, 'utf8') - if (!source.includes(GUARD) || !ESCAPE_COMPARISON.test(source)) { + if (!source.includes(GUARD) || !COMPOSITION_DISPATCH_COMPARISON.test(source)) { continue } for (const body of functionBodies(source)) { - const guardAt = body.indexOf(GUARD) - const escapeAt = body.search(ESCAPE_COMPARISON) const relativeFile = file.split(sep).join('/') - const requiresGuard = MUST_GUARD_EVERY_ESCAPE_HANDLER.has(relativeFile) - if ( - escapeAt !== -1 && - ((requiresGuard && guardAt === -1) || (guardAt !== -1 && escapeAt < guardAt)) - ) { + const requiresGuard = MUST_GUARD_EVERY_DISPATCH_HANDLER.has(relativeFile) + if (bodyDispatchesBeforeGuard(body, requiresGuard)) { offenders.add(file) } } } } - it('finds no keydown handler that dispatches Escape before consulting the guard', () => { + it('finds no reviewed keydown handler that dispatches before consulting the guard', () => { expect([...offenders]).toEqual([]) }) + it('catches a later unguarded handler and anonymous listener in a guarded file', () => { + const source = ` + const handleKeyDown = (event) => { + if (isImeCompositionKeyDown(event)) return + if (event.key === 'Escape') close() + } + const handleOtherKeyDown = (event) => { + if (event.key === 'Enter') submit() + } + window.addEventListener('keydown', (event) => { + if (['Escape'].includes(event.key)) close() + }) + ` + + expect( + functionBodies(source).filter((body) => bodyDispatchesBeforeGuard(body, true)) + ).toHaveLength(2) + }) + // A scanner that parses nothing reports zero offenders forever, so the coverage it claims is // asserted by name. MUST_SCAN holds the handler shapes the brace matcher has actually got wrong: // a hook whose destructured parameter list opens before its body, an inline JSX arrow, and a