fix(sidebar): restore native hover title rename

This commit is contained in:
Neil
2026-07-02 22:17:10 -07:00
parent 0d073a6fa8
commit b7cb87613f
5 changed files with 145 additions and 234 deletions
@@ -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}
@@ -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)
@@ -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<HTMLSpanElement>
inputRef: React.RefCallback<HTMLInputElement>
onStartRename: (event: React.MouseEvent<HTMLElement>) => void
onStopCardEvent: (event: React.SyntheticEvent) => void
onInputDoubleClick: (event: React.MouseEvent<HTMLInputElement>) => void
onInputDragStart: (event: React.DragEvent<HTMLInputElement>) => void
onInputMouseDown: (event: React.MouseEvent<HTMLInputElement>) => void
onInputSelect: (event: React.SyntheticEvent<HTMLInputElement>) => void
onInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => 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 (
<span
key={`field:${titleElementKey}`}
ref={rootRef}
className={cn(
'relative grid min-w-0 leading-tight text-foreground',
showUnreadEmphasis ? 'font-semibold' : 'font-normal',
className,
editing && editingClassName
)}
data-worktree-title-inline-rename={editing ? 'editing' : ''}
onDoubleClick={editing ? undefined : onStartRename}
>
<span
className={cn(
'pointer-events-none col-start-1 row-start-1 min-w-0',
editing
? 'invisible truncate whitespace-pre'
: wrapTitle
? 'break-words whitespace-normal'
: 'truncate whitespace-nowrap'
)}
aria-hidden="true"
>
{displayName}
</span>
<Input
ref={inputRef}
value={editing ? value : displayName}
style={{ font: 'inherit' }}
readOnly={!editing}
disabled={saving}
draggable={false}
spellCheck={false}
tabIndex={disabled ? -1 : 0}
aria-label={translate(
'auto.components.sidebar.WorktreeTitleInlineRename.bff3bdd00c',
'Rename workspace'
)}
data-worktree-title-rename-input={editing ? 'true' : undefined}
onChange={(event) => {
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 ? (
<LoaderCircle className="pointer-events-none absolute right-1.5 top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</span>
)
}
@@ -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', () => {
<WorktreeTitleInlineRename
displayName="Feature workspace"
editingPresentation="field"
selectOnFocus={false}
onRename={vi.fn()}
/>
)
@@ -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(
<WorktreeTitleInlineRename
displayName="Feature workspace"
editingPresentation="field"
onRename={vi.fn()}
/>
)
})
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', () => {
<WorktreeTitleInlineRename
displayName="Feature workspace"
editingPresentation="field"
selectOnFocus={false}
wrapTitle
onRename={vi.fn()}
/>
)
})
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()
@@ -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<HTMLInputElement>) => {
// 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
if (suppressMouseSelection) {
event.preventDefault()
}
stopCardEvent(event)
},
[stopCardEvent, suppressMouseSelection]
)
const handleInputSelect = useCallback(
(event: React.SyntheticEvent<HTMLInputElement>) => {
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 (
<WorktreeTitleFieldRename
titleElementKey={titleElementKey}
displayName={displayName}
value={value}
disabled={disabled}
editing={editing}
saving={saving}
showUnreadEmphasis={showUnreadEmphasis}
className={className}
editingClassName={editingClassName}
inputClassName={inputClassName}
wrapTitle={wrapTitle}
suppressMouseSelection={suppressMouseSelection}
rootRef={handleRootRef}
inputRef={handleInputRef}
onStartRename={startRename}
onStopCardEvent={stopCardEvent}
onInputDoubleClick={handleInputDoubleClick}
onInputDragStart={stopInputDragEvent}
onInputMouseDown={handleInputMouseDown}
onInputSelect={handleInputSelect}
onInputKeyDown={handleKeyDown}
onValueChange={setValue}
onCommitRename={() => void commitRename()}
/>
)
}
if (editing) {
return (
<span
@@ -374,7 +367,6 @@ export function WorktreeTitleInlineRename({
value={value}
style={{ font: 'inherit' }}
disabled={saving}
draggable={false}
spellCheck={false}
aria-label={translate(
'auto.components.sidebar.WorktreeTitleInlineRename.bff3bdd00c',
@@ -385,16 +377,14 @@ export function WorktreeTitleInlineRename({
onBlur={() => 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({
<LoaderCircle
className={cn(
'pointer-events-none absolute top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground',
'right-0'
savingSpinnerClassName
)}
/>
) : null}