diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/workspace-delete-quick-action.ts b/src/renderer/src/components/sidebar/workspace-actions/deletion/workspace-delete-quick-action.ts index 59816e85c2e..70d9bca518d 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/workspace-delete-quick-action.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/workspace-delete-quick-action.ts @@ -94,6 +94,15 @@ export function canShowWorkspaceDeleteQuickAction(args: { deleteModifierPressed: boolean isDeleting: boolean isMainWorktree: boolean + /** + * An Orca Cloud workspace's own checkout, owned or paired (OPTION-TRASH-CLOUD). It is the cloud + * workspace, not a repo's primary: the delete funnel destroys the sandbox after the cloud confirm. + */ + cloudPrimary?: boolean }): boolean { - return args.deleteModifierPressed && !args.isDeleting && !args.isMainWorktree + return ( + args.deleteModifierPressed && + !args.isDeleting && + (!args.isMainWorktree || args.cloudPrimary === true) + ) } diff --git a/src/renderer/src/components/sidebar/workspace-card/controller/use-worktree-card-controller.ts b/src/renderer/src/components/sidebar/workspace-card/controller/use-worktree-card-controller.ts index 6b69c13d76c..73e7cff81ce 100644 --- a/src/renderer/src/components/sidebar/workspace-card/controller/use-worktree-card-controller.ts +++ b/src/renderer/src/components/sidebar/workspace-card/controller/use-worktree-card-controller.ts @@ -1,4 +1,5 @@ import { useWorkspaceReferenceDetails } from '../review/use-workspace-reference-details' +import { useIsCloudPrimaryRow } from '@/lib/orca-cloud/cloud-host-rows' import { getWorkspaceAttachments } from '../../../../../../shared/workspace-attachments/workspace-attachments' import { summarizeWorkspaceReviewChecks } from '../../../../../../shared/workspace-review-checks' import { canShowWorkspaceDeleteQuickAction } from '../../workspace-actions/deletion/workspace-delete-quick-action' @@ -100,13 +101,16 @@ export function useWorktreeCardController(props: ResolvedWorktreeCardProps) { openModal: foundation.openModal }) + // A cloud workspace's own row (owned or paired) is deletable: the sandbox goes, after the cloud confirm. + const cloudPrimary = useIsCloudPrimaryRow(worktree) // Why: delete is destructive, so it only appears while holding Option/Alt, not in the ordinary hover chrome. const showDeleteQuickAction = !props.affiliateListMode && canShowWorkspaceDeleteQuickAction({ deleteModifierPressed: linked.deleteModifierPressed, isDeleting: linked.isDeleting, - isMainWorktree: worktree.isMainWorktree + isMainWorktree: worktree.isMainWorktree, + cloudPrimary }) const workspaceActions = useWorktreeCardWorkspaceActions({ worktree, diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardOptionDelete.fixture.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardOptionDelete.fixture.tsx new file mode 100644 index 00000000000..1e75e4574d6 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardOptionDelete.fixture.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react' +import { getDefaultSettings } from '../../../../../../shared/constants' +import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { seedCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' +import WorktreeCard from './WorktreeCard' + +// OPTION-TRASH-CLOUD: the sidebar while Option is held, each card as if hovered. A cloud workspace's +// own row (created here, or paired from Home) shows the trash like a local worktree; a local repo's +// primary checkout still doesn't (Git can't delete it). +const repo = { ...makeRepo(), displayName: 'orca' } +const worktrees = [ + makeWorktree('local-main', 'orca', { isMainWorktree: true }), + makeWorktree('local-wt', 'fix-login', { branch: 'refs/heads/fix-login' }), + makeWorktree('cloud-owned', 'Add a push test', { + isMainWorktree: true, + hostId: 'runtime:env-owned', + branch: 'refs/heads/add-push-test' + }), + makeWorktree('cloud-paired', 'Ada’s cloud workspace', { + isMainWorktree: true, + hostId: 'runtime:env-paired', + branch: 'refs/heads/ada/onboarding' + }) +] + +// The created workspace's sandbox server, and a workspace paired from Home. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cloud row check reads only id, name, source and orcaCloudPaired. +const CLOUD_SERVERS = [ + { id: 'env-owned', name: 'orca sandbox a1b2c3d4', source: 'ephemeral-vm' }, + { id: 'env-paired', name: 'orca (cloud)', orcaCloudPaired: true } +] as never + +function HoldOption({ children }: { children: React.ReactNode }): React.JSX.Element { + useEffect(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true })) + }, []) + return <>{children} +} + +export const optionHeld = defineFixture({ + width: 320, + store: { + settings: getDefaultSettings('/home/fixture'), + repos: [repo], + worktreesByRepo: { [repo.id]: worktrees }, + worktreeCardProperties: ['status', 'branch'], + runtimeEnvironments: CLOUD_SERVERS + }, + setup: () => seedCloudWorkspaceSnapshot({ ids: new Set(['cloud-owned']), idsLoaded: true }), + render: () => ( + + {/* Every card as if hovered: the trash only shows on the card under the pointer. */} + +
+ {worktrees.map((worktree) => ( + + ))} +
+
+ ) +}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-option-delete-cloud.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-option-delete-cloud.test.tsx new file mode 100644 index 00000000000..41f1559d5f6 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-option-delete-cloud.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +// OPTION-TRASH-CLOUD: holding Option shows the trash on a cloud workspace's own row (owned or +// paired), the live card's DeleteQuickAction, and it opens the cloud delete (sandbox), never the +// local "Remove Project". A local repo's primary checkout still gets no trash. +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' +import { seedCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' +import { canShowWorkspaceDeleteQuickAction } from '../../workspace-actions/deletion/workspace-delete-quick-action' +import WorktreeCard from './WorktreeCard' + +const mocks = vi.hoisted(() => ({ cloudDelete: vi.fn() })) +vi.mock('@/lib/orca-cloud/cloud-primary-delete-request', () => ({ + requestCloudPrimaryDelete: mocks.cloudDelete +})) +vi.mock('@/components/sidebar/workspace-actions/sleep/use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false, + selectIsSleepingWorktree: () => false +})) +vi.mock('@/components/sidebar/workspace-card/activity/use-worktree-activity-status', () => ({ + useWorktreeActivityStatus: () => 'inactive' +})) + +const repo = makeRepo() +const ownedCloud = makeWorktree('cloud-owned', 'Owned cloud', { isMainWorktree: true }) +const pairedCloud = makeWorktree('cloud-paired', 'Paired cloud', { + isMainWorktree: true, + hostId: 'runtime:env-paired' +}) +const localPrimary = makeWorktree('local-main', 'Local primary', { isMainWorktree: true }) +const localWorktree = makeWorktree('local-wt', 'Local worktree') + +function seed(): void { + act(() => { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [ownedCloud, pairedCloud, localPrimary, localWorktree] }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cloud row check reads only id and orcaCloudPaired. + runtimeEnvironments: [ + { id: 'env-paired', name: 'web (cloud)', orcaCloudPaired: true } + ] as never + }) + seedCloudWorkspaceSnapshot({ ids: new Set(['cloud-owned']), idsLoaded: true }) + }) +} + +function card(worktree: ReturnType) { + return render( + + + + ) +} + +const trash = (container: HTMLElement) => + container.querySelector('button[aria-label="Delete workspace"]') + +afterEach(() => { + act(() => { + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt' })) + }) + cleanup() + seedCloudWorkspaceSnapshot({}) + mocks.cloudDelete.mockReset() +}) + +describe('Option shows the trash on cloud rows (OPTION-TRASH-CLOUD)', () => { + it('the gate: a cloud primary is deletable, a local primary is not', () => { + const held = { deleteModifierPressed: true, isDeleting: false } + expect(canShowWorkspaceDeleteQuickAction({ ...held, isMainWorktree: true })).toBe(false) + expect( + canShowWorkspaceDeleteQuickAction({ ...held, isMainWorktree: true, cloudPrimary: true }) + ).toBe(true) + expect(canShowWorkspaceDeleteQuickAction({ ...held, isMainWorktree: false })).toBe(true) + // Never without Option, never while deleting. + expect( + canShowWorkspaceDeleteQuickAction({ + deleteModifierPressed: false, + isDeleting: false, + isMainWorktree: true, + cloudPrimary: true + }) + ).toBe(false) + expect( + canShowWorkspaceDeleteQuickAction({ + deleteModifierPressed: true, + isDeleting: true, + isMainWorktree: true, + cloudPrimary: true + }) + ).toBe(false) + }) + + it.each([ + ['owned', ownedCloud], + ['paired', pairedCloud] + ])('%s cloud row: Option shows the trash and it opens the cloud delete', (_kind, worktree) => { + seed() + const view = card(worktree) + expect(trash(view.container)).toBeNull() + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true })) + }) + const button = trash(view.container) + expect(button).not.toBeNull() + fireEvent.click(button!) + expect(mocks.cloudDelete).toHaveBeenCalledWith(expect.objectContaining({ id: worktree.id })) + expect(useAppStore.getState().activeModal).not.toBe('confirm-remove-folder') + }) + + it('a local primary still gets no trash; a local worktree does', () => { + seed() + const primary = card(localPrimary) + const worktree = card(localWorktree) + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true })) + }) + expect(trash(primary.container)).toBeNull() + expect(trash(worktree.container)).not.toBeNull() + }) +})