mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(ui): restore IME Enter protection in workspace details
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 `<p>` 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(
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
|
||||
@@ -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<HTMLTextAreaElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
[handleSave]
|
||||
)
|
||||
const handleIssueKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user