diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index c7fb2ee5926..f36b0999c26 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -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) => { + event.stopPropagation() + }, + [] + ) + const handleDeleteQuickAction = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + if (showDeleteQuickAction) { + runWorktreeDelete(worktree.id) + } + }, + [showDeleteQuickAction, worktree.id] + ) return ( ) : null} + {showDeleteQuickAction && ( + + + + + + Delete workspace + + + )} = {} let ptyIdsByTabId: Record = {} let browserTabsByWorktree: Record = {} let settings: Partial | 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() + 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( + + ) + + 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( ) @@ -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( { 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( + + ) + + 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( + + ) + + 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' }] } diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 1339912f165..e7f3eeb31b7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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) => { 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 ? ( @@ -777,7 +773,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ {showTitleRowDetails && detailsAndPorts} - {showDeleteQuickAction && !isDeleting && ( + {showDeleteQuickAction && (