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