fix: close workspace hovercard on context menu (#2347)
This commit is contained in:
parent
f010bb8619
commit
73ff6e43dd
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useMemo } from 'react'
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Pin } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -10,6 +10,7 @@ import type { Repo, Worktree } from '../../../../shared/types'
|
|||
import WorktreeCard from './WorktreeCard'
|
||||
import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import { getWorkspaceKanbanDetailsHoverOpenState } from './workspace-kanban-details-hover'
|
||||
import { writeWorkspaceDragData } from './workspace-status'
|
||||
|
||||
type WorkspaceKanbanCardProps = {
|
||||
|
|
@ -106,6 +107,8 @@ function WorkspaceKanbanCompactCard({
|
|||
}: Omit<WorkspaceKanbanCardProps, 'compact'>): React.JSX.Element {
|
||||
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
|
||||
const isDeleting = deleteState?.isDeleting ?? false
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const contextMenuOpenRef = useRef(false)
|
||||
const contextWorktrees = useMemo(
|
||||
() =>
|
||||
isSelected && selectedWorktrees && selectedWorktrees.length > 0
|
||||
|
|
@ -150,13 +153,45 @@ function WorkspaceKanbanCompactCard({
|
|||
[contextWorktrees, isDeleting, isSelected, worktree.id]
|
||||
)
|
||||
|
||||
const handleDetailsOpenChange = useCallback((requestedOpen: boolean) => {
|
||||
setDetailsOpen(
|
||||
getWorkspaceKanbanDetailsHoverOpenState({
|
||||
contextMenuOpen: contextMenuOpenRef.current,
|
||||
requestedOpen
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleContextMenuOpenChange = useCallback((open: boolean) => {
|
||||
contextMenuOpenRef.current = open
|
||||
if (open) {
|
||||
// Why: the preview sits beside the compact card, so it should disappear
|
||||
// as soon as the card's context menu becomes the active surface.
|
||||
setDetailsOpen(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleContextMenuSelect = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
setDetailsOpen(false)
|
||||
return onContextMenuSelect(event, worktree)
|
||||
},
|
||||
[onContextMenuSelect, worktree]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorktreeContextMenu
|
||||
worktree={worktree}
|
||||
selectedWorktrees={contextWorktrees}
|
||||
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
|
||||
onContextMenuSelect={handleContextMenuSelect}
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
>
|
||||
<HoverCard openDelay={450} closeDelay={100}>
|
||||
<HoverCard
|
||||
open={detailsOpen}
|
||||
onOpenChange={handleDetailsOpenChange}
|
||||
openDelay={450}
|
||||
closeDelay={100}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -209,7 +244,12 @@ function WorkspaceKanbanCompactCard({
|
|||
) : null}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" sideOffset={8} className="w-72 p-1.5">
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-72 p-1.5 data-[state=closed]:hidden"
|
||||
>
|
||||
<WorktreeCard
|
||||
worktree={worktree}
|
||||
repo={repo}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type Props = {
|
|||
contentClassName?: string
|
||||
selectedWorktrees?: readonly Worktree[]
|
||||
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => readonly Worktree[]
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
|
@ -154,7 +155,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
children,
|
||||
contentClassName,
|
||||
selectedWorktrees = [worktree],
|
||||
onContextMenuSelect
|
||||
onContextMenuSelect,
|
||||
onOpenChange
|
||||
}: Props) {
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
|
||||
|
|
@ -225,11 +227,19 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null
|
||||
const hasAnyContextLineage = activeContextWorktrees.some((item) => worktreeLineageById[item.id])
|
||||
|
||||
const setMenuOpenState = useCallback(
|
||||
(open: boolean) => {
|
||||
setMenuOpen(open)
|
||||
onOpenChange?.(open)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => setMenuOpen(false)
|
||||
const closeMenu = (): void => setMenuOpenState(false)
|
||||
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
}, [])
|
||||
}, [setMenuOpenState])
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
window.api.ui.writeClipboardText(worktree.path)
|
||||
|
|
@ -245,7 +255,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
|
||||
const handleAssignWorkspaceStatus = useCallback(
|
||||
(status: string) => {
|
||||
setMenuOpen(false)
|
||||
setMenuOpenState(false)
|
||||
void Promise.all(
|
||||
activeContextWorktrees.map((item) =>
|
||||
getWorkspaceStatus(item, workspaceStatuses) === status
|
||||
|
|
@ -254,7 +264,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
)
|
||||
)
|
||||
},
|
||||
[activeContextWorktrees, updateWorktreeMeta, workspaceStatuses]
|
||||
[activeContextWorktrees, setMenuOpenState, updateWorktreeMeta, workspaceStatuses]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
|
|
@ -277,14 +287,14 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
|
||||
const handleCloseTerminals = useCallback(() => {
|
||||
const worktreeIds = sleepableWorktrees.map((item) => item.id)
|
||||
setMenuOpen(false)
|
||||
setMenuOpenState(false)
|
||||
// Why: Sleep can remount the sidebar when it clears the active workspace.
|
||||
// Let Radix finish closing the menu first so its focus/portal teardown
|
||||
// cannot scroll the virtualized list during that remount.
|
||||
window.setTimeout(() => {
|
||||
void runSleepWorktrees(worktreeIds)
|
||||
}, 50)
|
||||
}, [sleepableWorktrees])
|
||||
}, [setMenuOpenState, sleepableWorktrees])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
// Folder mode handled inline because it routes to a different modal;
|
||||
|
|
@ -293,7 +303,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
scopeRef.current
|
||||
?.closest('[data-worktree-sidebar]')
|
||||
?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT))
|
||||
setMenuOpen(false)
|
||||
setMenuOpenState(false)
|
||||
// Why: Delete can remove the active row and remount the sidebar. Run it
|
||||
// after menu close for the same reason as Sleep above.
|
||||
window.setTimeout(() => {
|
||||
|
|
@ -325,6 +335,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
isFolder,
|
||||
isMultiContext,
|
||||
openModal,
|
||||
setMenuOpenState,
|
||||
worktree.displayName,
|
||||
worktree.id,
|
||||
worktree.repoId
|
||||
|
|
@ -389,14 +400,14 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
setContextWorktrees(onContextMenuSelect?.(event) ?? selectedWorktrees)
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
|
||||
setMenuOpen(true)
|
||||
setMenuOpenState(true)
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
suppressOpeningPointerEvent(event)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpenState} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorkspaceKanbanDetailsHoverOpenState } from './workspace-kanban-details-hover'
|
||||
|
||||
describe('getWorkspaceKanbanDetailsHoverOpenState', () => {
|
||||
it('keeps the details hover closed while the context menu is open', () => {
|
||||
expect(
|
||||
getWorkspaceKanbanDetailsHoverOpenState({
|
||||
contextMenuOpen: true,
|
||||
requestedOpen: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('follows hover requests when the context menu is closed', () => {
|
||||
expect(
|
||||
getWorkspaceKanbanDetailsHoverOpenState({
|
||||
contextMenuOpen: false,
|
||||
requestedOpen: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
getWorkspaceKanbanDetailsHoverOpenState({
|
||||
contextMenuOpen: false,
|
||||
requestedOpen: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export function getWorkspaceKanbanDetailsHoverOpenState({
|
||||
contextMenuOpen,
|
||||
requestedOpen
|
||||
}: {
|
||||
contextMenuOpen: boolean
|
||||
requestedOpen: boolean
|
||||
}): boolean {
|
||||
return contextMenuOpen ? false : requestedOpen
|
||||
}
|
||||
Loading…
Reference in New Issue