fix: require click for workspace board (#2038)

This commit is contained in:
Jinjing 2026-05-15 20:34:56 -07:00 committed by GitHub
parent b609aa2bec
commit e88d8fbea8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 12 additions and 181 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { Kanban, Plus, SlidersHorizontal } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
@ -52,24 +52,9 @@ const SORT_OPTIONS = [
const isMac = navigator.userAgent.includes('Mac')
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
const WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS = 50
// Why: gives the pointer room to travel from the header into the board before
// the temporary hover preview collapses.
const WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS = 220
type WorkspaceBoardOpenMode = 'closed' | 'hover' | 'persistent'
const SidebarHeader = React.memo(function SidebarHeader() {
const [workspaceBoardOpenMode, setWorkspaceBoardOpenMode] =
useState<WorkspaceBoardOpenMode>('closed')
const workspaceBoardOpen = workspaceBoardOpenMode !== 'closed'
const workspaceBoardPersistentOpen = workspaceBoardOpenMode === 'persistent'
// Why: hover-open and manual-open have different close semantics; keeping
// the mode explicit prevents a button click from closing a hover preview.
const workspaceBoardOpenModeRef = useRef<WorkspaceBoardOpenMode>('closed')
const workspaceBoardHoverSuppressedRef = useRef(false)
const workspaceHeaderHoveredRef = useRef(false)
const workspaceBoardHoverOpenTimerRef = useRef<number | null>(null)
const workspaceBoardHoverCloseTimerRef = useRef<number | null>(null)
const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
@ -83,146 +68,13 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage)
const setShowWorkspaceLineage = useAppStore((s) => s.setShowWorkspaceLineage)
const clearWorkspaceBoardHoverClose = useCallback(() => {
if (workspaceBoardHoverCloseTimerRef.current === null) {
return
}
window.clearTimeout(workspaceBoardHoverCloseTimerRef.current)
workspaceBoardHoverCloseTimerRef.current = null
const handleWorkspaceBoardOpenChange = useCallback((open: boolean) => {
setWorkspaceBoardOpen(open)
}, [])
const clearWorkspaceBoardHoverOpen = useCallback(() => {
if (workspaceBoardHoverOpenTimerRef.current === null) {
return
}
window.clearTimeout(workspaceBoardHoverOpenTimerRef.current)
workspaceBoardHoverOpenTimerRef.current = null
}, [])
useEffect(
() => () => {
clearWorkspaceBoardHoverOpen()
clearWorkspaceBoardHoverClose()
},
[clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen]
)
const setWorkspaceBoardMode = useCallback((mode: WorkspaceBoardOpenMode) => {
workspaceBoardOpenModeRef.current = mode
setWorkspaceBoardOpenMode(mode)
}, [])
const handleWorkspaceBoardOpenChange = useCallback(
(open: boolean) => {
clearWorkspaceBoardHoverOpen()
clearWorkspaceBoardHoverClose()
if (open) {
workspaceBoardHoverSuppressedRef.current = false
setWorkspaceBoardMode('persistent')
return
}
setWorkspaceBoardMode('closed')
workspaceBoardHoverSuppressedRef.current = workspaceHeaderHoveredRef.current
},
[clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode]
)
const handleWorkspaceHeaderPointerEnter = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'mouse') {
return
}
workspaceHeaderHoveredRef.current = true
clearWorkspaceBoardHoverClose()
if (
workspaceBoardOpenModeRef.current !== 'closed' ||
workspaceBoardHoverSuppressedRef.current
) {
return
}
clearWorkspaceBoardHoverOpen()
workspaceBoardHoverOpenTimerRef.current = window.setTimeout(() => {
workspaceBoardHoverOpenTimerRef.current = null
if (
workspaceBoardHoverSuppressedRef.current ||
workspaceBoardOpenModeRef.current !== 'closed'
) {
return
}
setWorkspaceBoardMode('hover')
}, WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS)
},
[clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode]
)
const handleWorkspaceHeaderPointerLeave = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse') {
const rect = event.currentTarget.getBoundingClientRect()
if (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
) {
return
}
}
workspaceHeaderHoveredRef.current = false
workspaceBoardHoverSuppressedRef.current = false
clearWorkspaceBoardHoverOpen()
if (event.pointerType !== 'mouse' || workspaceBoardOpenModeRef.current === 'persistent') {
return
}
clearWorkspaceBoardHoverClose()
workspaceBoardHoverCloseTimerRef.current = window.setTimeout(() => {
workspaceBoardHoverCloseTimerRef.current = null
if (workspaceBoardOpenModeRef.current === 'persistent') {
return
}
setWorkspaceBoardMode('closed')
}, WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS)
},
[clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode]
)
const handleWorkspaceBoardPointerEnter = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'mouse') {
return
}
clearWorkspaceBoardHoverOpen()
clearWorkspaceBoardHoverClose()
workspaceBoardHoverSuppressedRef.current = false
setWorkspaceBoardMode('persistent')
},
[clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode]
)
const handleWorkspaceBoardButtonPointerDown = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) {
return
}
clearWorkspaceBoardHoverOpen()
clearWorkspaceBoardHoverClose()
},
[clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen]
)
const handleWorkspaceBoardToggle = useCallback(() => {
clearWorkspaceBoardHoverOpen()
clearWorkspaceBoardHoverClose()
if (workspaceBoardOpenModeRef.current === 'persistent') {
workspaceBoardHoverSuppressedRef.current = workspaceHeaderHoveredRef.current
setWorkspaceBoardMode('closed')
return
}
workspaceBoardHoverSuppressedRef.current = false
setWorkspaceBoardMode('persistent')
}, [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardMode])
setWorkspaceBoardOpen((open) => !open)
}, [])
useEffect(() => {
if (!workspaceBoardOpen) {
@ -233,31 +85,19 @@ const SidebarHeader = React.memo(function SidebarHeader() {
if (event.key !== 'Escape') {
return
}
clearWorkspaceBoardHoverOpen()
clearWorkspaceBoardHoverClose()
workspaceBoardHoverSuppressedRef.current = workspaceHeaderHoveredRef.current
event.preventDefault()
setWorkspaceBoardMode('closed')
setWorkspaceBoardOpen(false)
}
// Why: the workspace board is a non-modal companion panel, so focus may
// be outside the sheet when Escape should still dismiss it.
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [
clearWorkspaceBoardHoverClose,
clearWorkspaceBoardHoverOpen,
setWorkspaceBoardMode,
workspaceBoardOpen
])
}, [workspaceBoardOpen])
return (
<>
<div
className="mt-2 flex h-8 items-center justify-between px-2 gap-2"
onPointerEnter={handleWorkspaceHeaderPointerEnter}
onPointerLeave={handleWorkspaceHeaderPointerLeave}
>
<div className="mt-2 flex h-8 items-center justify-between px-2 gap-2">
<div className="flex min-w-0 items-center gap-1">
<span className="px-2 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
Workspaces
@ -269,20 +109,15 @@ const SidebarHeader = React.memo(function SidebarHeader() {
size="icon-xs"
className="text-muted-foreground"
aria-label="Workspace board"
aria-pressed={workspaceBoardPersistentOpen}
aria-pressed={workspaceBoardOpen}
data-workspace-board-trigger=""
onPointerDown={handleWorkspaceBoardButtonPointerDown}
onClick={handleWorkspaceBoardToggle}
>
<Kanban className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{workspaceBoardPersistentOpen
? 'Close workspace board'
: workspaceBoardOpen
? 'Keep workspace board open'
: 'Workspace board'}
{workspaceBoardOpen ? 'Close workspace board' : 'Workspace board'}
</TooltipContent>
</Tooltip>
</div>
@ -417,7 +252,6 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<WorkspaceKanbanDrawer
open={workspaceBoardOpen}
onOpenChange={handleWorkspaceBoardOpenChange}
onPointerEnter={handleWorkspaceBoardPointerEnter}
/>
</>
)

View File

@ -19,7 +19,6 @@ import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
type WorkspaceKanbanDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
onPointerEnter?: React.PointerEventHandler<HTMLDivElement>
}
function sortBoardWorktrees(a: Worktree, b: Worktree): number {
@ -28,8 +27,7 @@ function sortBoardWorktrees(a: Worktree, b: Worktree): number {
export default function WorkspaceKanbanDrawer({
open,
onOpenChange,
onPointerEnter
onOpenChange
}: WorkspaceKanbanDrawerProps): React.JSX.Element {
const allWorktrees = useAllWorktrees()
const repoMap = useRepoMap()
@ -307,7 +305,6 @@ export default function WorkspaceKanbanDrawer({
} as React.CSSProperties
}
data-workspace-board-compact={workspaceBoardCompact ? 'true' : 'false'}
onPointerEnter={onPointerEnter}
onOpenAutoFocus={(event) => {
// Why: Radix focuses the first toolbar button on open, which opens
// its tooltip without hover and makes the drawer feel noisy.