mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Show workspace delete action with Option
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user