mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
fix(sidebar): stop hover title cursor flicker at the source
The hovercard rename field auto-selected its whole title on open. On macOS a pointer parked over a fresh selection flips between the I-beam and the native drag-selection arrow — the "cursor keeps changing" flicker. The prior fix tried to mask it with a global `cursor: text !important` style injection, which cannot override that OS-level drag cursor. Fix it structurally instead: the field editor opens with the caret at the end (no selection), so there is nothing to drag and the cursor stays a stable I-beam. Delete the entire cursor-lock subsystem — no other inline rename in the app needs one. The inline sidebar row keeps its select-all one-keystroke replace. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -26,8 +26,6 @@ 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', () => {
|
||||
@@ -117,7 +115,8 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
|
||||
setSelectionRange.mockRestore()
|
||||
})
|
||||
|
||||
it('selects the full title when hovercard field mode starts editing', () => {
|
||||
it('places the caret at the end when hovercard field mode starts editing', () => {
|
||||
const displayName = 'Feature workspace'
|
||||
const select = vi.spyOn(HTMLInputElement.prototype, 'select')
|
||||
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange')
|
||||
const nextContainer = document.createElement('div')
|
||||
@@ -127,7 +126,7 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<WorktreeTitleInlineRename
|
||||
displayName="Feature workspace"
|
||||
displayName={displayName}
|
||||
editingPresentation="field"
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
@@ -141,8 +140,11 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
|
||||
})
|
||||
|
||||
const input = nextContainer.querySelector('[data-worktree-title-rename-input]')
|
||||
expect(select).toHaveBeenCalledTimes(1)
|
||||
expect(setSelectionRange).not.toHaveBeenCalled()
|
||||
// Why: the hovercard field opens with the caret at the end (no selection) so a
|
||||
// pointer parked over the title never flips between the I-beam and the macOS
|
||||
// drag-selection arrow.
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
expect(setSelectionRange).toHaveBeenCalledWith(displayName.length, displayName.length)
|
||||
expect(input?.className).toContain('select-text')
|
||||
expect(input?.className).not.toContain('select-none')
|
||||
|
||||
@@ -150,52 +152,6 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
|
||||
setSelectionRange.mockRestore()
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -25,50 +25,6 @@ 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
|
||||
@@ -87,8 +43,9 @@ type WorktreeTitleInlineRenameProps = {
|
||||
// onBeginEditingConsumed so the request fires exactly once.
|
||||
beginEditing?: boolean
|
||||
onBeginEditingConsumed?: () => void
|
||||
// Why: parent-triggered rename can place the caret without selecting, while
|
||||
// double-click rename keeps the native one-keystroke replacement behavior.
|
||||
// Why: the inline text row selects all for a one-keystroke replace; the hovercard
|
||||
// field defaults to caret-at-end. A pointer parked over a fresh selection makes
|
||||
// macOS flip between the I-beam and the drag-selection arrow. Callers can override.
|
||||
selectOnFocus?: boolean
|
||||
}
|
||||
|
||||
@@ -107,7 +64,7 @@ export function WorktreeTitleInlineRename({
|
||||
onRename,
|
||||
beginEditing = false,
|
||||
onBeginEditingConsumed,
|
||||
selectOnFocus = true
|
||||
selectOnFocus = editingPresentation !== 'field'
|
||||
}: WorktreeTitleInlineRenameProps): React.JSX.Element {
|
||||
const editingRef = useRef(false)
|
||||
const savingRef = useRef(false)
|
||||
@@ -185,13 +142,6 @@ export function WorktreeTitleInlineRename({
|
||||
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) => {
|
||||
if (editingRef.current === nextEditing) {
|
||||
|
||||
Reference in New Issue
Block a user