Show workspace delete action with Option

This commit is contained in:
Neil
2026-05-29 18:46:24 -07:00
committed by GitHub
parent 69b3a9d948
commit 55fc8d3ac8
4 changed files with 218 additions and 23 deletions
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Pin } from 'lucide-react'
import { Pin, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
@@ -14,6 +14,11 @@ import WorktreeContextMenu from './WorktreeContextMenu'
import { getWorkspaceKanbanDetailsHoverOpenState } from './workspace-kanban-details-hover'
import { writeWorkspaceDragData } from './workspace-status'
import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename'
import { runWorktreeDelete } from './delete-worktree-flow'
import {
canShowWorkspaceDeleteQuickAction,
useWorkspaceDeleteModifierPressed
} from './workspace-delete-quick-action'
type WorkspaceKanbanCardProps = {
worktree: Worktree
@@ -111,6 +116,7 @@ function WorkspaceKanbanCompactCard({
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const openModal = useAppStore((s) => s.openModal)
const isDeleting = deleteState?.isDeleting ?? false
const deleteModifierPressed = useWorkspaceDeleteModifierPressed()
const [detailsOpen, setDetailsOpen] = useState(false)
const [titleRenaming, setTitleRenaming] = useState(false)
const contextMenuOpenRef = useRef(false)
@@ -216,6 +222,27 @@ function WorkspaceKanbanCompactCard({
},
[onContextMenuSelect, worktree]
)
const showDeleteQuickAction = canShowWorkspaceDeleteQuickAction({
deleteModifierPressed,
isDeleting,
isMainWorktree: worktree.isMainWorktree
})
const stopQuickActionPropagation = useCallback(
(event: React.SyntheticEvent<HTMLButtonElement>) => {
event.stopPropagation()
},
[]
)
const handleDeleteQuickAction = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
if (showDeleteQuickAction) {
runWorktreeDelete(worktree.id)
}
},
[showDeleteQuickAction, worktree.id]
)
return (
<WorktreeContextMenu
@@ -240,7 +267,7 @@ function WorkspaceKanbanCompactCard({
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
className={cn(
'flex h-8 w-full min-w-0 cursor-pointer items-center rounded-md border px-2 text-left text-[12px] outline-none transition-colors',
'group relative flex h-8 w-full min-w-0 cursor-pointer items-center rounded-md border px-2 text-left text-[12px] outline-none transition-colors',
isActive
? 'border-sidebar-ring bg-sidebar-accent text-sidebar-accent-foreground'
: isSelected
@@ -249,6 +276,7 @@ function WorkspaceKanbanCompactCard({
isActive && isSelected && 'ring-1 ring-sidebar-ring/35',
'data-[workspace-board-card-area-selected=true]:border-sidebar-ring/50 data-[workspace-board-card-area-selected=true]:bg-sidebar-accent/75 data-[workspace-board-card-area-selected=true]:ring-1 data-[workspace-board-card-area-selected=true]:ring-sidebar-ring/30',
!nativeDragEnabled && !isDeleting && '!cursor-grab',
showDeleteQuickAction && 'pr-7',
titleRenaming && '!border-transparent !bg-transparent !ring-0 cursor-default',
isDeleting && 'cursor-not-allowed opacity-50 grayscale'
)}
@@ -288,6 +316,30 @@ function WorkspaceKanbanCompactCard({
</TooltipContent>
</Tooltip>
) : null}
{showDeleteQuickAction && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-workspace-board-preserve-open=""
onPointerDown={stopQuickActionPropagation}
onKeyDown={stopQuickActionPropagation}
onClick={handleDeleteQuickAction}
className={cn(
'absolute right-1 top-1 z-20 inline-flex size-5 items-center justify-center rounded border border-sidebar-border bg-sidebar/95 text-muted-foreground opacity-0 shadow-xs transition-colors transition-opacity',
'group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
'hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring'
)}
aria-label="Delete workspace"
>
<Trash2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Delete workspace
</TooltipContent>
</Tooltip>
)}
</div>
</HoverCardTrigger>
<HoverCardContent
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import type WorktreeCardComponent from './WorktreeCard'
import type * as WorkspaceDeleteQuickAction from './workspace-delete-quick-action'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
@@ -14,6 +15,7 @@ let tabsByWorktree: Record<string, { id: string }[]> = {}
let ptyIdsByTabId: Record<string, string[]> = {}
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
let settings: Partial<GlobalSettings> | null = null
let workspaceDeleteModifierPressed = false
let WorktreeCard: typeof WorktreeCardComponent
vi.mock('@/store', () => ({
@@ -72,6 +74,14 @@ vi.mock('./WorktreeContextMenu', () => ({
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
}))
vi.mock('./workspace-delete-quick-action', async (importOriginal) => {
const actual = await importOriginal<typeof WorkspaceDeleteQuickAction>()
return {
...actual,
useWorkspaceDeleteModifierPressed: () => workspaceDeleteModifierPressed
}
})
function makeRepo(): Repo {
return {
id: 'repo-1',
@@ -117,6 +127,7 @@ describe('WorktreeCard quick actions', () => {
ptyIdsByTabId = {}
browserTabsByWorktree = {}
settings = null
workspaceDeleteModifierPressed = false
})
it('marks the unread toggle as a workspace-board-preserving action', () => {
@@ -225,7 +236,17 @@ describe('WorktreeCard quick actions', () => {
expect(markup).not.toContain('data-worktree-card-meta-row=""')
})
it('shows delete as the top-right quick action for an inactive workspace', () => {
it('hides delete by default for an inactive workspace', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('shows delete as the top-right quick action while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
@@ -233,7 +254,9 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('aria-label="Delete workspace"')
})
it('shows delete as the quick action for inactive folder workspace instances', () => {
it('shows delete as the quick action for folder workspace instances while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
@@ -249,6 +272,31 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('aria-label="Delete workspace"')
})
it('shows delete for a current workspace while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const worktree = makeWorktree()
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive isCurrentWorktree />
)
expect(markup).toContain('aria-label="Delete workspace"')
})
it('does not show delete for the main worktree while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ isMainWorktree: true })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('does not replace sleep with delete for a workspace with live activity', () => {
const worktree = makeWorktree()
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
@@ -45,11 +45,14 @@ import { writeWorkspaceDragData } from './workspace-status'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
import { runWorktreeDelete } from './delete-worktree-flow'
import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename'
import {
canShowWorkspaceDeleteQuickAction,
useWorkspaceDeleteModifierPressed
} from './workspace-delete-quick-action'
type WorktreeCardProps = {
worktree: Worktree
@@ -91,7 +94,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
isActive,
isCurrentWorktree = isActive,
isActiveSurface = isActive,
isMultiSelected = false,
selectedWorktrees,
@@ -249,14 +251,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
: null
const isDeleting = deleteState?.isDeleting ?? false
const hasActiveActivity = useAppStore((s) =>
hasActiveWorkspaceActivity(
worktree.id,
s.tabsByWorktree,
s.ptyIdsByTabId,
s.browserTabsByWorktree
)
)
const deleteModifierPressed = useWorkspaceDeleteModifierPressed()
const showPR = cardProps.includes('pr')
const showIssue = cardProps.includes('issue')
@@ -434,9 +429,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
},
[worktree.id, worktree.isUnread, updateWorktreeMeta]
)
// Why: deleting the active/current workspace or one with live activity is a
// disruptive hover action; keep the quick action delete-only and passive.
const showDeleteQuickAction = !isCurrentWorktree && !hasActiveActivity && !worktree.isMainWorktree
// Why: delete is destructive, so it only appears while the user is holding
// Option/Alt instead of being part of the ordinary hover chrome.
const showDeleteQuickAction = canShowWorkspaceDeleteQuickAction({
deleteModifierPressed,
isDeleting,
isMainWorktree: worktree.isMainWorktree
})
const handleWorkspaceQuickAction = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
@@ -569,10 +568,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const showTitleRowDetails = compactCards && (hasDetails || hasPorts)
const showMetaRowDetails = !compactCards && (hasDetails || hasPorts)
const showHeaderActions =
showTitleRowUnread ||
showTitleRowPrimary ||
showTitleRowDetails ||
(showDeleteQuickAction && !isDeleting)
showTitleRowUnread || showTitleRowPrimary || showTitleRowDetails || showDeleteQuickAction
const unreadQuickAction = showUnreadQuickAction ? (
<Tooltip>
@@ -777,7 +773,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{showTitleRowDetails && detailsAndPorts}
{showDeleteQuickAction && !isDeleting && (
{showDeleteQuickAction && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -788,7 +784,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
className={cn(
'inline-flex size-4 items-center justify-center rounded bg-transparent opacity-0 transition-colors transition-opacity',
'group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
'text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:bg-transparent focus-visible:text-foreground'
'text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive'
)}
aria-label="Delete workspace"
>
@@ -0,0 +1,99 @@
import { useSyncExternalStore } from 'react'
let deleteModifierPressed = false
let listenersInstalled = false
const listeners = new Set<() => void>()
function notifyListeners(): void {
for (const listener of listeners) {
listener()
}
}
function setDeleteModifierPressed(next: boolean): void {
if (deleteModifierPressed === next) {
return
}
deleteModifierPressed = next
notifyListeners()
}
function onKeyDown(event: KeyboardEvent): void {
if (event.altKey || event.key === 'Alt') {
setDeleteModifierPressed(true)
}
}
function onKeyUp(event: KeyboardEvent): void {
if (event.key === 'Alt' || !event.altKey) {
setDeleteModifierPressed(false)
}
}
function clearDeleteModifierPressed(): void {
setDeleteModifierPressed(false)
}
function installListeners(): void {
if (listenersInstalled || typeof window === 'undefined') {
return
}
listenersInstalled = true
// Why: Option/Alt is a transient reveal modifier, so stale key state after
// focus loss would leave destructive affordances visible.
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', clearDeleteModifierPressed)
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', clearDeleteModifierPressed)
}
}
function uninstallListeners(): void {
if (!listenersInstalled || typeof window === 'undefined') {
return
}
listenersInstalled = false
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', clearDeleteModifierPressed)
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', clearDeleteModifierPressed)
}
clearDeleteModifierPressed()
}
function subscribeDeleteModifier(listener: () => void): () => void {
listeners.add(listener)
installListeners()
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
uninstallListeners()
}
}
}
function getDeleteModifierSnapshot(): boolean {
return deleteModifierPressed
}
function getServerDeleteModifierSnapshot(): boolean {
return false
}
export function useWorkspaceDeleteModifierPressed(): boolean {
return useSyncExternalStore(
subscribeDeleteModifier,
getDeleteModifierSnapshot,
getServerDeleteModifierSnapshot
)
}
export function canShowWorkspaceDeleteQuickAction(args: {
deleteModifierPressed: boolean
isDeleting: boolean
isMainWorktree: boolean
}): boolean {
return args.deleteModifierPressed && !args.isDeleting && !args.isMainWorktree
}