mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
fix(sidebar): keep hover title field stable
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user