From 41892272d8d11ff4ee890481d7000aaa15e54479 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:49:47 -0700 Subject: [PATCH] fix(sidebar): keep hover title field stable --- .../sidebar/WorktreeTitleFieldRename.tsx | 125 ++++++++++++++++++ ...rktreeTitleInlineRename.lifecycle.test.tsx | 36 +++++ .../sidebar/WorktreeTitleInlineRename.tsx | 44 ++++-- 3 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx new file mode 100644 index 00000000000..b1a68eb5ef6 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeTitleFieldRename.tsx @@ -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 + 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 = + '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 ( + + + { + 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 34b7e18d25a..f5692439c64 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.lifecycle.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.lifecycle.test.tsx @@ -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( + + ) + }) + + 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 + }) + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 79b6bfd1bb4..c4717551716 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -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 ( + void commitRename()} + /> + ) + } + if (editing) { return ( @@ -375,7 +401,7 @@ export function WorktreeTitleInlineRename({ ) : null}