diff --git a/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx b/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx index d48cf90e7d9..77e6cd953cb 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardHoverIdentityHeader.tsx @@ -57,7 +57,6 @@ export function WorktreeCardHoverIdentityHeader({ displayName={workspaceTitle} disabled={workspaceTitleRenameDisabled} editingPresentation="field" - selectOnFocus={false} wrapTitle className={cn( 'text-[13px] font-semibold leading-snug text-foreground', @@ -65,9 +64,7 @@ export function WorktreeCardHoverIdentityHeader({ identityOrder === 'branch-first' && 'mt-1' )} editingClassName={cn( - // Why: keep field padding on the wrapper so native input padding doesn't - // expose arrow-cursor dead zones between glyphs and the field edge. - '-mx-1.5 w-[calc(100%+0.75rem)] cursor-text px-1.5 text-[13px] leading-snug', + '-mx-1.5 w-[calc(100%+0.75rem)] cursor-text text-[13px] leading-snug', identityOrder === 'branch-first' && 'mt-1' )} onEditingChange={handleWorkspaceTitleEditingChange} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index d966fc24d63..db7132900a2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -220,10 +220,10 @@ describe('WorktreeCardDetailsHover interactions', () => { expect(identityHeader?.className).toContain('cursor-text') expect(input).not.toBeNull() expect(input?.className).toContain('cursor-text') - expect(input?.className).toContain('select-none') - expect(input?.className).not.toContain('select-text') + expect(input?.className).toContain('select-text') + expect(input?.className).not.toContain('select-none') expect(sizingTitle?.className).toContain('pointer-events-none') - expect(input?.getAttribute('draggable')).toBe('false') + expect(input?.getAttribute('draggable')).toBeNull() expect(input?.className).toContain('bg-input/40') expect(input?.className).toContain('rounded-sm') expect(input?.className).toContain('selection:bg-[Highlight]') @@ -235,7 +235,15 @@ describe('WorktreeCardDetailsHover interactions', () => { input?.dispatchEvent(dragStart) }) - expect(dragStart.defaultPrevented).toBe(true) + expect(dragStart.defaultPrevented).toBe(false) + + const doubleClick = new MouseEvent('dblclick', { bubbles: true, cancelable: true }) + + act(() => { + input?.dispatchEvent(doubleClick) + }) + + expect(doubleClick.defaultPrevented).toBe(false) act(() => { interactionMocks.onHoverOpenChange?.(false) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx deleted file mode 100644 index 05839e5febd..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' -import { Input } from '@/components/ui/input' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' - -type WorktreeTitleFieldRenameProps = { - titleElementKey: string - displayName: string - value: string - disabled: boolean - editing: boolean - saving: boolean - showUnreadEmphasis: boolean - className?: string - editingClassName?: string - inputClassName?: string - wrapTitle: boolean - suppressMouseSelection: boolean - rootRef: React.RefCallback - inputRef: React.RefCallback - onStartRename: (event: React.MouseEvent) => void - onStopCardEvent: (event: React.SyntheticEvent) => void - onInputDoubleClick: (event: React.MouseEvent) => void - onInputDragStart: (event: React.DragEvent) => void - onInputMouseDown: (event: React.MouseEvent) => void - onInputSelect: (event: React.SyntheticEvent) => void - onInputKeyDown: (event: React.KeyboardEvent) => void - onValueChange: (value: string) => void - onCommitRename: () => void -} - -const fieldEditingInputClassName = - 'h-6 rounded-sm border border-input bg-input/40 px-0 py-0 shadow-xs selection:bg-[Highlight] selection:text-[HighlightText] focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/50 dark:bg-input/30' -const fieldReadInputClassName = - 'absolute inset-0 h-full min-h-[1lh] rounded-none border-0 !border-transparent !bg-transparent p-0 !text-transparent !shadow-none caret-transparent selection:bg-transparent selection:text-transparent focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-none dark:!bg-transparent' - -export function WorktreeTitleFieldRename({ - titleElementKey, - displayName, - value, - disabled, - editing, - saving, - showUnreadEmphasis, - className, - editingClassName, - inputClassName, - wrapTitle, - suppressMouseSelection, - rootRef, - inputRef, - onStartRename, - onStopCardEvent, - onInputDoubleClick, - onInputDragStart, - onInputMouseDown, - onInputSelect, - onInputKeyDown, - onValueChange, - onCommitRename -}: WorktreeTitleFieldRenameProps): React.JSX.Element { - const fieldInputCursorClassName = disabled ? 'cursor-default' : 'cursor-text' - - return ( - - - { - if (editing) { - onValueChange(event.target.value) - } - }} - onBlur={editing ? onCommitRename : undefined} - onClick={onStopCardEvent} - onDoubleClick={editing ? onInputDoubleClick : onStartRename} - onDragStart={onInputDragStart} - onMouseDown={onInputMouseDown} - onPointerDown={onStopCardEvent} - onSelect={editing ? onInputSelect : undefined} - onKeyDown={editing ? onInputKeyDown : onStopCardEvent} - className={cn( - // Why: the hovercard title keeps the same native text field before - // and during rename so the OS cursor never has to refresh a new node. - 'col-start-1 row-start-1 min-w-0 truncate text-foreground outline-none', - fieldInputCursorClassName, - suppressMouseSelection ? 'select-none' : 'select-text', - editing ? fieldEditingInputClassName : fieldReadInputClassName, - saving && 'pr-6', - inputClassName - )} - /> - {saving ? ( - - ) : null} - - ) -} diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.lifecycle.test.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.lifecycle.test.tsx index 96ce797fdf6..3042f97b8b7 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.lifecycle.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.lifecycle.test.tsx @@ -26,6 +26,8 @@ describe('WorktreeTitleInlineRename lifecycle', () => { root = null container?.remove() container = null + document.documentElement.removeAttribute('data-worktree-title-rename-cursor-lock') + document.getElementById('worktree-title-rename-cursor-lock-style')?.remove() }) it('reports editing=false if it unmounts while renaming', () => { @@ -115,7 +117,9 @@ describe('WorktreeTitleInlineRename lifecycle', () => { setSelectionRange.mockRestore() }) - it('suppresses mouse selection in hovercard field mode', () => { + it('selects the full title when hovercard field mode starts editing', () => { + const select = vi.spyOn(HTMLInputElement.prototype, 'select') + const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange') const nextContainer = document.createElement('div') container = nextContainer root = createRoot(nextContainer) @@ -125,7 +129,6 @@ describe('WorktreeTitleInlineRename lifecycle', () => { ) @@ -138,11 +141,62 @@ describe('WorktreeTitleInlineRename lifecycle', () => { }) const input = nextContainer.querySelector('[data-worktree-title-rename-input]') - expect(input?.className).toContain('select-none') - expect(input?.className).not.toContain('select-text') + expect(select).toHaveBeenCalledTimes(1) + expect(setSelectionRange).not.toHaveBeenCalled() + expect(input?.className).toContain('select-text') + expect(input?.className).not.toContain('select-none') + + select.mockRestore() + setSelectionRange.mockRestore() }) - it('keeps the hovercard field input mounted while entering edit mode', () => { + it('locks the document cursor only while the hovercard field editor is active', () => { + const nextContainer = document.createElement('div') + container = nextContainer + root = createRoot(nextContainer) + + act(() => { + root?.render( + + ) + }) + + expect(document.documentElement.hasAttribute('data-worktree-title-rename-cursor-lock')).toBe( + false + ) + + act(() => { + nextContainer + .querySelector('[data-worktree-title-inline-rename]') + ?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })) + }) + + expect(document.documentElement.hasAttribute('data-worktree-title-rename-cursor-lock')).toBe( + true + ) + expect( + document.getElementById('worktree-title-rename-cursor-lock-style')?.textContent + ).toContain('cursor: text !important') + + act(() => { + nextContainer + .querySelector('[data-worktree-title-rename-input]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + }) + + expect(document.documentElement.hasAttribute('data-worktree-title-rename-cursor-lock')).toBe( + false + ) + expect(document.getElementById('worktree-title-rename-cursor-lock-style')).toBeNull() + }) + + it('renders wrapped read text before mounting the hovercard field input', () => { const nextContainer = document.createElement('div') container = nextContainer root = createRoot(nextContainer) @@ -152,22 +206,16 @@ describe('WorktreeTitleInlineRename lifecycle', () => { ) }) - const readInput = nextContainer.querySelector('input') - const readSizingTitle = nextContainer.querySelector( - '[data-worktree-title-inline-rename] [aria-hidden="true"]' - ) - expect(readInput?.readOnly).toBe(true) - expect(readInput?.className).toContain('!text-transparent') - expect(readSizingTitle?.className).toContain('break-words') - expect(readSizingTitle?.className).toContain('whitespace-normal') - expect(readInput?.getAttribute('data-worktree-title-rename-input')).toBeNull() + const readTitle = nextContainer.querySelector('[data-worktree-title-inline-rename]') + expect(nextContainer.querySelector('input')).toBeNull() + expect(readTitle?.className).toContain('break-words') + expect(readTitle?.className).toContain('whitespace-normal') act(() => { nextContainer @@ -176,8 +224,10 @@ describe('WorktreeTitleInlineRename lifecycle', () => { }) const editingInput = nextContainer.querySelector('[data-worktree-title-rename-input]') - expect(editingInput).toBe(readInput) + expect(editingInput).not.toBeNull() expect((editingInput as HTMLInputElement | null)?.readOnly).toBe(false) + expect(editingInput?.className).toContain('bg-input/40') + expect(editingInput?.className).toContain('select-text') act(() => { root?.unmount() diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 071458cf7f0..8c5de69426f 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { LoaderCircle } from 'lucide-react' import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import { WorktreeTitleFieldRename } from './WorktreeTitleFieldRename' export type WorktreeTitleRenameCommit = { kind: 'cancel' } | { kind: 'save'; displayName: string } @@ -26,6 +25,50 @@ export function isWorktreeTitleTruncated( return element.scrollWidth > element.clientWidth } +const FIELD_RENAME_CURSOR_LOCK_ATTR = 'data-worktree-title-rename-cursor-lock' +const FIELD_RENAME_CURSOR_LOCK_STYLE_ID = 'worktree-title-rename-cursor-lock-style' +const useCursorLockEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect + +let fieldRenameCursorLockCount = 0 + +function acquireFieldRenameCursorLock(): () => void { + if (typeof document === 'undefined') { + return () => {} + } + + fieldRenameCursorLockCount += 1 + document.documentElement.setAttribute(FIELD_RENAME_CURSOR_LOCK_ATTR, '') + + if (!document.getElementById(FIELD_RENAME_CURSOR_LOCK_STYLE_ID)) { + const style = document.createElement('style') + style.id = FIELD_RENAME_CURSOR_LOCK_STYLE_ID + // Why: Chromium can leave the native cursor stale after a stationary pointer + // swaps from wrapped text to an input. Lock cursor only while field editing. + style.textContent = ` +html[${FIELD_RENAME_CURSOR_LOCK_ATTR}], +html[${FIELD_RENAME_CURSOR_LOCK_ATTR}] body, +html[${FIELD_RENAME_CURSOR_LOCK_ATTR}] body * { + cursor: text !important; +} +` + document.head.append(style) + } + + let released = false + return () => { + if (released) { + return + } + released = true + fieldRenameCursorLockCount = Math.max(0, fieldRenameCursorLockCount - 1) + if (fieldRenameCursorLockCount > 0) { + return + } + document.documentElement.removeAttribute(FIELD_RENAME_CURSOR_LOCK_ATTR) + document.getElementById(FIELD_RENAME_CURSOR_LOCK_STYLE_ID)?.remove() + } +} + type WorktreeTitleInlineRenameProps = { displayName: string disabled?: boolean @@ -44,8 +87,8 @@ type WorktreeTitleInlineRenameProps = { // onBeginEditingConsumed so the request fires exactly once. beginEditing?: boolean onBeginEditingConsumed?: () => void - // Why: fully selected rename text makes native inputs flip the OS cursor between - // I-beam and arrow while moving over the hovercard field editor. + // Why: parent-triggered rename can place the caret without selecting, while + // double-click rename keeps the native one-keystroke replacement behavior. selectOnFocus?: boolean } @@ -136,8 +179,18 @@ export function WorktreeTitleInlineRename({ // Why: the sidebar row needs a text-only editor to avoid layout jumps; the // hovercard can use a compact field that reads more like native rename UI. const editingInputClassName = - 'h-[1lh] rounded-none border-0 !border-transparent !bg-transparent p-0 !shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-none dark:!bg-transparent' - const suppressMouseSelection = editingPresentation === 'field' && !selectOnFocus + editingPresentation === 'field' + ? 'h-6 rounded-sm border border-input bg-input/40 px-1.5 py-0 shadow-xs selection:bg-[Highlight] selection:text-[HighlightText] focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/50 dark:bg-input/30' + : 'h-[1lh] rounded-none border-0 !border-transparent !bg-transparent p-0 !shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-none dark:!bg-transparent' + const savingInputClassName = editingPresentation === 'field' ? 'pr-6' : 'pr-4' + const savingSpinnerClassName = editingPresentation === 'field' ? 'right-1.5' : 'right-0' + + useCursorLockEffect(() => { + if (!editing || editingPresentation !== 'field') { + return + } + return acquireFieldRenameCursorLock() + }, [editing, editingPresentation]) const setEditingMode = useCallback( (nextEditing: boolean) => { @@ -171,8 +224,8 @@ export function WorktreeTitleInlineRename({ if (!input || !editing) { return } - // Why: defer focus until after the opening double-click finishes so the - // browser doesn't leave a word/line selection that flips the OS cursor. + // Why: defer focus until after the opening double-click finishes so + // intentional select-all wins over the browser's initiating click target. renameFocusFrameRef.current = requestAnimationFrame(() => { renameFocusFrameRef.current = null input.focus() @@ -207,48 +260,18 @@ export function WorktreeTitleInlineRename({ event.stopPropagation() }, []) - const stopInputDragEvent = useCallback((event: React.DragEvent) => { - // Why: selected rename text can become a drag target, which flips the cursor - // away from the I-beam while the title editor is active. - event.preventDefault() - event.stopPropagation() - }, []) - const handleInputMouseDown = useCallback( (event: React.MouseEvent) => { - if (suppressMouseSelection && event.detail > 1) { - // Why: native inputs select words/lines on multi-click, which makes macOS - // alternate arrow and I-beam cursors over the hovercard field editor. - event.preventDefault() - } stopCardEvent(event) }, - [stopCardEvent, suppressMouseSelection] + [stopCardEvent] ) const handleInputDoubleClick = useCallback( (event: React.MouseEvent) => { - if (suppressMouseSelection) { - event.preventDefault() - } stopCardEvent(event) }, - [stopCardEvent, suppressMouseSelection] - ) - - const handleInputSelect = useCallback( - (event: React.SyntheticEvent) => { - if (!suppressMouseSelection) { - return - } - const input = event.currentTarget - if (input.selectionStart === input.selectionEnd) { - return - } - const caret = input.value.length - input.setSelectionRange(caret, caret) - }, - [suppressMouseSelection] + [stopCardEvent] ) const startRename = useCallback( @@ -320,36 +343,6 @@ export function WorktreeTitleInlineRename({ [cancelRename, commitRename] ) - if (editingPresentation === 'field') { - return ( - void commitRename()} - /> - ) - } - if (editing) { return ( void commitRename()} onClick={stopCardEvent} onDoubleClick={handleInputDoubleClick} - onDragStart={stopInputDragEvent} onMouseDown={handleInputMouseDown} onPointerDown={stopCardEvent} - onSelect={handleInputSelect} onKeyDown={handleKeyDown} className={cn( 'col-start-1 row-start-1 min-w-0 cursor-text truncate text-foreground outline-none', - suppressMouseSelection ? 'select-none' : 'select-text', + 'select-text', editingInputClassName, - saving && 'pr-4', + saving && savingInputClassName, inputClassName )} /> @@ -402,7 +392,7 @@ export function WorktreeTitleInlineRename({ ) : null}