mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(renderer): guard remaining IME text-entry handlers
This commit is contained in:
@@ -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<HTMLTextAreaElement>) => {
|
||||
if (isImeCompositionKeyDown(event)) {
|
||||
return
|
||||
}
|
||||
if (!isScreenSubmitShortcut(event)) {
|
||||
return
|
||||
}
|
||||
@@ -172,6 +176,9 @@ export function LinearIssueTextEditor({
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isImeCompositionKeyDown(event)) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,11 +19,11 @@ function createSearchAddon(): SearchAddon {
|
||||
} as unknown as SearchAddon
|
||||
}
|
||||
|
||||
function renderSearch(searchAddon: SearchAddon): ReturnType<typeof render> {
|
||||
function renderSearch(searchAddon: SearchAddon, onClose = vi.fn()): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TerminalSearch
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
onClose={onClose}
|
||||
searchAddon={searchAddon}
|
||||
searchStateRef={{ current: { query: '', caseSensitive: false, regex: false } }}
|
||||
/>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
<BrowserFind
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
webviewRef={{ current: webview as unknown as Electron.WebviewTag }}
|
||||
/>
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
<PdfFind
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
eventBusRef={{ current: eventBus as unknown as InstanceType<typeof EventBus> }}
|
||||
/>
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
<RichMarkdownSearchBar
|
||||
activeMatchIndex={0}
|
||||
isOpen
|
||||
isReplaceMode
|
||||
matchCase={false}
|
||||
matchCount={2}
|
||||
query="needle"
|
||||
replaceQuery="replacement"
|
||||
replaceDisabled={false}
|
||||
searchInputRef={{ current: null }}
|
||||
wholeWord={false}
|
||||
{...actions}
|
||||
/>
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '네' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
<CustomAddressDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
validate={(input) => (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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(<ProjectCombobox options={projects} value={null} onValueChange={onValueChange} />)
|
||||
})
|
||||
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()
|
||||
|
||||
@@ -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<HTMLInputElement>): void => {
|
||||
if (isImeCompositionKeyDown(event)) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
<FontAutocomplete
|
||||
value={value}
|
||||
suggestions={['Arial', 'Courier New', 'Geist', 'JetBrains Mono', 'SF Mono']}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />)
|
||||
})
|
||||
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(<RemoteFileBrowser targetId="target-1" onSelect={vi.fn()} onCancel={onCancel} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
if (isImeCompositionKeyDown(e)) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (preview) {
|
||||
// Path mode Enter.
|
||||
|
||||
@@ -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<HTMLTextAreaElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
if (isImeCompositionKeyDown(e)) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
|
||||
@@ -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<string>()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user