diff --git a/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx index 715c9976e17..fe661f4ba09 100644 --- a/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx +++ b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx @@ -4,6 +4,10 @@ import { Label } from '@/components/ui/label' import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { translate } from '@/i18n/i18n' +import { + isImeOwnedKeyboardEvent, + useImeEnterGestureOwnership +} from '@/lib/ime-composition-keyboard-event' type WorktreeDisplayNameFieldProps = { disabled: boolean @@ -23,6 +27,7 @@ export function WorktreeDisplayNameField({ value }: WorktreeDisplayNameFieldProps): React.JSX.Element { const inputId = useId() + const enterGesture = useImeEnterGestureOwnership() const emojiInput = useWorkspaceEmojiShortcodeInput({ disabled, inputRef, @@ -42,9 +47,19 @@ export function WorktreeDisplayNameField({ onChange={(event) => emojiInput.handleValueChange(event.target.value, event.target.selectionStart) } + onCompositionStart={() => enterGesture.setComposing(true)} + onCompositionEnd={() => enterGesture.setComposing(false)} + onKeyUp={enterGesture.onKeyUp} + onBlur={enterGesture.reset} onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} onKeyDown={(event) => { - if (emojiInput.handleKeyDown(event) || event.key !== 'Enter') { + if ( + enterGesture.ownsKeyDown(event) || + enterGesture.isComposing() || + isImeOwnedKeyboardEvent(event) || + emojiInput.handleKeyDown(event) || + event.key !== 'Enter' + ) { return } event.preventDefault() diff --git a/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx index 6a7306d0100..41dea51c2e6 100644 --- a/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx +++ b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useId, useMemo } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { + isImeOwnedKeyboardEvent, + useImeEnterGestureOwnership +} from '@/lib/ime-composition-keyboard-event' import { DropdownMenu, DropdownMenuContent, @@ -88,6 +92,7 @@ export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): Reac onKeyDown } = props + const enterGesture = useImeEnterGestureOwnership() // Why: the helper `

` is the only surface for the invalid and displacement // states, so it has to be announced with the field rather than just seen. const helperId = useId() @@ -172,7 +177,20 @@ export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): Reac aria-describedby={helperId} value={value} onChange={(e) => onValueChange(e.target.value)} - onKeyDown={onKeyDown} + onCompositionStart={() => enterGesture.setComposing(true)} + onCompositionEnd={() => enterGesture.setComposing(false)} + onKeyUp={enterGesture.onKeyUp} + onBlur={enterGesture.reset} + onKeyDown={(event) => { + if ( + enterGesture.ownsKeyDown(event) || + enterGesture.isComposing() || + isImeOwnedKeyboardEvent(event) + ) { + return + } + onKeyDown(event) + }} disabled={isReadOnly} aria-invalid={isInvalid || undefined} placeholder={translate( diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index d367c6ef44c..498e3f04313 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -223,9 +223,112 @@ describe('WorktreeMetaDialog issue link row', () => { afterEach(() => { cleanup() + vi.restoreAllMocks() useAppStore.setState(initialState, true) }) + it.each([ + 'Notes about this worktree...', + 'Custom display name...', + 'Issue #, or a GitHub or Linear URL', + 'PR # or GitHub URL' + ])('ignores IME Enter and preserves ordinary Enter in %s', async (placeholder) => { + openDialog() + const input = screen.getByPlaceholderText(placeholder) + for (const marker of [{ isComposing: true, keyCode: 13 }, { keyCode: 229 }]) { + expect(fireEvent.keyDown(input, { key: 'Enter', ...marker })).toBe(true) + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('edit-meta') + } + fireEvent.blur(input) + await act(async () => { + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }) + }) + expect(updateWorktreeMeta).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().activeModal).toBe('none') + }) + + it.each( + ['before keyup', 'after keyup'].flatMap((order) => + [ + { + placeholder: 'Notes about this worktree...', + value: '日本語のノート', + updates: { comment: '日本語のノート' } + }, + { + placeholder: 'Custom display name...', + value: '日本語の名前', + updates: { displayName: '日本語の名前' } + }, + { + placeholder: 'Issue #, or a GitHub or Linear URL', + value: '42', + updates: { linkedIssue: 42 } + }, + { placeholder: 'PR # or GitHub URL', value: '43', updates: { linkedPR: 43 } }, + { placeholder: 'MR ! or GitLab URL', value: '!44', updates: { linkedGitLabMR: 44 } } + ].map((field) => ({ ...field, order })) + ) + )( + 'ignores the IME Enter redispatch $order in $placeholder', + async ({ order, placeholder, value, updates }) => { + openDialog(placeholder === 'MR ! or GitLab URL' ? { modalReviewProvider: 'gitlab' } : {}) + const input = screen.getByPlaceholderText(placeholder) + const frames: FrameRequestCallback[] = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => + frames.push(callback) + ) + fireEvent.compositionStart(input) + fireEvent.keyDown(input, { key: 'Process', keyCode: 229, isComposing: true }) + fireEvent.change(input, { target: { value } }) + fireEvent.compositionEnd(input) + if (order === 'after keyup') { + fireEvent.keyUp(input, { key: 'Enter', keyCode: 13 }) + } + expect(fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 })).toBe(false) + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('edit-meta') + fireEvent.keyUp(input, { key: 'Enter', keyCode: 13 }) + act(() => frames.forEach((callback) => callback(0))) + await act(async () => { + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }) + }) + expect(updateWorktreeMeta.mock.calls[0]?.[1]).toEqual(expect.objectContaining(updates)) + expect(updateWorktreeMeta).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().activeModal).toBe('none') + } + ) + + it.each([ + { userAgent: 'Macintosh', modifier: { metaKey: true } }, + { userAgent: 'Windows', modifier: { ctrlKey: true } }, + { userAgent: 'Linux', modifier: { ctrlKey: true } } + ])( + 'keeps the save shortcut outside composition on $userAgent', + async ({ userAgent, modifier }) => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent) + openDialog() + const input = screen.getByPlaceholderText('Notes about this worktree...') + fireEvent.compositionStart(input) + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, ...modifier }) + expect(updateWorktreeMeta).not.toHaveBeenCalled() + fireEvent.compositionEnd(input) + await act(async () => { + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, ...modifier }) + }) + expect(updateWorktreeMeta).toHaveBeenCalledTimes(1) + } + ) + + it('leaves Shift+Enter available for a newline', () => { + openDialog() + const input = screen.getByPlaceholderText('Notes about this worktree...') + expect(fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, shiftKey: true })).toBe(true) + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('edit-meta') + }) + it('seeds the chip and value from a GitHub link', () => { openDialog({ worktree: { linkedIssue: 42 } }) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 87b67039a0f..eff5c48fd38 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -34,6 +34,10 @@ import { import { parseExecutionHostId } from '../../../../shared/execution-host' import { WorktreeDisplayNameField } from './WorktreeDisplayNameField' import { WorktreeReviewLinkField } from './WorktreeReviewLinkField' +import { + isImeOwnedKeyboardEvent, + useImeEnterGestureOwnership +} from '@/lib/ime-composition-keyboard-event' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' @@ -55,6 +59,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const closeModal = useAppStore((s) => s.closeModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const submitShortcutLabel = getScreenSubmitShortcutLabel() + const commentIme = useImeEnterGestureOwnership() const isEditMeta = activeModal === 'edit-meta' const isOpen = isEditMeta @@ -290,6 +295,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const handleCommentKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (commentIme.ownsKeyDown(e) || commentIme.isComposing() || isImeOwnedKeyboardEvent(e)) { + return + } const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey if (isPlainEnter || isScreenSubmitShortcut(e)) { e.preventDefault() @@ -297,18 +305,15 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { handleSave() } }, - [handleSave] + [commentIme, handleSave] ) - const handleIssueKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSave() - } - }, - [handleSave] - ) + const handleIssueKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + e.preventDefault() + handleSave() + } + } return (

@@ -390,6 +395,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { ref={setCommentTextareaRef} value={commentInput} onChange={handleCommentChange} + onCompositionStart={() => commentIme.setComposing(true)} + onCompositionEnd={() => commentIme.setComposing(false)} + onKeyUp={commentIme.onKeyUp} + onBlur={commentIme.reset} onKeyDown={handleCommentKeyDown} placeholder={translate( 'auto.components.sidebar.WorktreeMetaDialog.030d484fc0', diff --git a/src/renderer/src/components/sidebar/WorktreeReviewLinkField.tsx b/src/renderer/src/components/sidebar/WorktreeReviewLinkField.tsx index 76c4adb3f04..33c3afbae6e 100644 --- a/src/renderer/src/components/sidebar/WorktreeReviewLinkField.tsx +++ b/src/renderer/src/components/sidebar/WorktreeReviewLinkField.tsx @@ -1,5 +1,9 @@ import type { RefObject } from 'react' import { Input } from '@/components/ui/input' +import { + isImeOwnedKeyboardEvent, + useImeEnterGestureOwnership +} from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import type { WorktreeReviewProvider } from './worktree-meta-updates' @@ -18,6 +22,7 @@ export function WorktreeReviewLinkField({ provider, value }: WorktreeReviewLinkFieldProps): React.JSX.Element { + const enterGesture = useImeEnterGestureOwnership() const isGitLab = provider === 'gitlab' return (
@@ -30,7 +35,20 @@ export function WorktreeReviewLinkField({ ref={inputRef} value={value} onChange={(event) => onValueChange(event.target.value)} - onKeyDown={onKeyDown} + onCompositionStart={() => enterGesture.setComposing(true)} + onCompositionEnd={() => enterGesture.setComposing(false)} + onKeyUp={enterGesture.onKeyUp} + onBlur={enterGesture.reset} + onKeyDown={(event) => { + if ( + enterGesture.ownsKeyDown(event) || + enterGesture.isComposing() || + isImeOwnedKeyboardEvent(event) + ) { + return + } + onKeyDown(event) + }} placeholder={ isGitLab ? translate(