fix(sidebar): keep hover title field stable

This commit is contained in:
Neil
2026-07-02 16:49:47 -07:00
parent 4c8cc9ad4a
commit 41892272d8
3 changed files with 196 additions and 9 deletions
@@ -0,0 +1,125 @@
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
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 =
'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'
export function WorktreeTitleFieldRename({
titleElementKey,
displayName,
value,
disabled,
editing,
saving,
showUnreadEmphasis,
className,
editingClassName,
inputClassName,
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 truncate 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="pointer-events-none invisible col-start-1 row-start-1 min-w-0 truncate whitespace-pre"
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>
)
}
@@ -141,4 +141,40 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
expect(input?.className).toContain('select-none')
expect(input?.className).not.toContain('select-text')
})
it('keeps the hovercard field input mounted while entering edit mode', () => {
const nextContainer = document.createElement('div')
container = nextContainer
root = createRoot(nextContainer)
act(() => {
root?.render(
<WorktreeTitleInlineRename
displayName="Feature workspace"
editingPresentation="field"
selectOnFocus={false}
onRename={vi.fn()}
/>
)
})
const readInput = nextContainer.querySelector('input')
expect(readInput?.readOnly).toBe(true)
expect(readInput?.getAttribute('data-worktree-title-rename-input')).toBeNull()
act(() => {
nextContainer
.querySelector('[data-worktree-title-inline-rename]')
?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
})
const editingInput = nextContainer.querySelector('[data-worktree-title-rename-input]')
expect(editingInput).toBe(readInput)
expect((editingInput as HTMLInputElement | null)?.readOnly).toBe(false)
act(() => {
root?.unmount()
root = null
})
})
})
@@ -5,6 +5,7 @@ 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 }
@@ -135,12 +136,8 @@ 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 =
editingPresentation === 'field'
? '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'
: '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'
'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
const savingInputClassName = editingPresentation === 'field' ? 'pr-6' : 'pr-4'
const savingSpinnerClassName = editingPresentation === 'field' ? 'right-1.5' : 'right-0'
const setEditingMode = useCallback(
(nextEditing: boolean) => {
@@ -171,7 +168,7 @@ export function WorktreeTitleInlineRename({
const handleInputRef = useCallback(
(input: HTMLInputElement | null) => {
clearRenameFocusFrame()
if (!input) {
if (!input || !editing) {
return
}
// Why: defer focus until after the opening double-click finishes so the
@@ -188,7 +185,7 @@ export function WorktreeTitleInlineRename({
input.setSelectionRange(caret, caret)
})
},
[clearRenameFocusFrame, selectOnFocus]
[clearRenameFocusFrame, editing, selectOnFocus]
)
// Why: open the editor when a parent requests it (the workspace.rename
@@ -323,6 +320,35 @@ 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}
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
@@ -367,7 +393,7 @@ export function WorktreeTitleInlineRename({
'col-start-1 row-start-1 min-w-0 cursor-text truncate text-foreground outline-none',
suppressMouseSelection ? 'select-none' : 'select-text',
editingInputClassName,
saving && savingInputClassName,
saving && 'pr-4',
inputClassName
)}
/>
@@ -375,7 +401,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',
savingSpinnerClassName
'right-0'
)}
/>
) : null}