fix: require click for workspace board (#2038)
This commit is contained in:
parent
b609aa2bec
commit
e88d8fbea8
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue