mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): CLOUD-PRIMARY-DELETE a cloud workspace on its default branch (owned or paired) is deletable: Delete workspace… opens the cloud delete dialog and destroys the sandbox; no primary rule, no Remove Project, no primary badge
This commit is contained in:
@@ -7,6 +7,7 @@ import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-acco
|
||||
import { BrowserExtensionInstallDialog } from './components/browser-extension-install-dialog'
|
||||
import { PlansDialogHost } from './components/billing/plans/PlansDialogHost'
|
||||
import { CloudMoveToComputerDialogMount } from './components/orca-cloud/CloudMoveToComputerDialogMount'
|
||||
import { CloudPrimaryDeleteDialogMount } from './components/orca-cloud/CloudPrimaryDeleteDialog'
|
||||
import { CloudSecretsAskDialogHost } from './components/cloud-secrets-ask/CloudSecretsMissingNotice'
|
||||
import { OrcaCloudSignInSheet } from './components/settings/agent-accounts/sign-in-sheet/OrcaCloudSignInSheet'
|
||||
import { AgentHostPanel } from './components/agent-host-panel/AgentHostPanel'
|
||||
@@ -128,6 +129,7 @@ function App(): React.JSX.Element {
|
||||
<BrowserExtensionInstallDialog />
|
||||
<PlansDialogHost />
|
||||
<CloudMoveToComputerDialogMount />
|
||||
<CloudPrimaryDeleteDialogMount />
|
||||
<CloudSecretsAskDialogHost />
|
||||
<AgentHostPanel />
|
||||
<OrcaCloudSignInSheet />
|
||||
|
||||
@@ -63,6 +63,18 @@ function asResult(
|
||||
return { ok: false, code: 'failed', message, userMessage: message }
|
||||
}
|
||||
|
||||
/** The control plane archives a sandbox this window cannot archive itself (paired, or no row). */
|
||||
export function archiveSandboxThroughControlPlane(
|
||||
sandboxId: string,
|
||||
name: string,
|
||||
deps: HomeArchiveDeps = defaultDeps()
|
||||
): Promise<boolean> {
|
||||
return deps.archiveWithUndo(`home:${sandboxId}`, {
|
||||
name,
|
||||
commit: async () => asResult(await deps.patchArchived(sandboxId, true))
|
||||
})
|
||||
}
|
||||
|
||||
export async function archiveFromHome(
|
||||
w: CloudWorkspace,
|
||||
name: string,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// CLOUD-PRIMARY-DELETE: the cloud workspace's own row deletes its sandbox through the control plane.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { destroyCloudPrimary } from './CloudPrimaryDeleteDialog'
|
||||
import type { CloudPrimaryDeleteTarget } from '@/lib/orca-cloud/cloud-primary-delete-request'
|
||||
import { resetCloudWorkspaceIdentityForTests } from '@/components/cloud-workspaces/cloud-workspace-identity'
|
||||
|
||||
function stub(
|
||||
destroy: () => Promise<unknown>,
|
||||
bindings: Record<string, { environmentId: string }>
|
||||
) {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
cloudWorkspaces: {
|
||||
destroy: vi.fn(destroy),
|
||||
localWorkspaces: async () => [],
|
||||
environmentBindings: async () => bindings
|
||||
}
|
||||
}
|
||||
})
|
||||
return window.api.cloudWorkspaces.destroy
|
||||
}
|
||||
|
||||
const row: CloudPrimaryDeleteTarget = {
|
||||
id: 'wt-main',
|
||||
hostId: 'runtime:env-cloud',
|
||||
displayName: 'ik-main',
|
||||
repoId: 'r',
|
||||
branch: 'main'
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
vi.mocked(toast.error).mockClear()
|
||||
resetCloudWorkspaceIdentityForTests()
|
||||
})
|
||||
|
||||
describe('destroyCloudPrimary', () => {
|
||||
it('destroys the paired sandbox behind the row and says so', async () => {
|
||||
const destroy = stub(async () => ({ ok: true, value: null }), {
|
||||
sbx_paired: { environmentId: 'env-cloud' }
|
||||
})
|
||||
await destroyCloudPrimary(row)
|
||||
expect(destroy).toHaveBeenCalledWith('sbx_paired')
|
||||
expect(toast.success).toHaveBeenCalledWith('Deleted ik-main')
|
||||
})
|
||||
|
||||
it('a failed destroy is an error, never "Deleted"', async () => {
|
||||
stub(async () => ({ ok: false, error: 'unavailable' }), {
|
||||
sbx_paired: { environmentId: 'env-cloud' }
|
||||
})
|
||||
await destroyCloudPrimary(row)
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith('Couldn’t delete ik-main', expect.anything())
|
||||
})
|
||||
|
||||
it('no sandbox found: an error naming where to delete it, nothing destroyed', async () => {
|
||||
const destroy = stub(async () => ({ ok: true, value: null }), {})
|
||||
await destroyCloudPrimary(row)
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith('Couldn’t delete ik-main', {
|
||||
description: "Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces."
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
// CLOUD-PRIMARY-DELETE: Delete for a cloud row that IS the cloud workspace (the sandbox's own
|
||||
// checkout, shown as "primary"), including paired ones this desktop did not create. A local removal
|
||||
// cannot delete it (Git keeps a primary checkout; a paired sandbox isn't this desktop's to destroy),
|
||||
// so this is the cloud delete dialog from Home and the sidebar, acting through the control plane:
|
||||
// Delete destroys the sandbox, Archive instead archives it (with Undo).
|
||||
import React, { useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@/components/ui/dialog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
DeleteCloudWorkspaceFooter,
|
||||
DeleteCloudWorkspaceHeader
|
||||
} from '@/components/sidebar/workspace-actions/deletion/DeleteCloudWorkspaceParts'
|
||||
import { describeError } from '@/components/cloud-workspaces/cloud-workspace-errors'
|
||||
import { archiveSandboxThroughControlPlane } from '@/components/cloud-workspaces/cloud-workspace-home-archive'
|
||||
import { noteCloudArchiveChanged } from '@/lib/orca-cloud/cloud-archive-changes'
|
||||
import { resolveCloudRowSandboxId } from '@/lib/orca-cloud/cloud-host-rows'
|
||||
import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity'
|
||||
import {
|
||||
cloudPrimaryDeleteRequest,
|
||||
requestCloudPrimaryDelete,
|
||||
subscribeCloudPrimaryDelete,
|
||||
type CloudPrimaryDeleteTarget
|
||||
} from '@/lib/orca-cloud/cloud-primary-delete-request'
|
||||
|
||||
type Target = CloudPrimaryDeleteTarget
|
||||
const setRequested = requestCloudPrimaryDelete
|
||||
|
||||
const NO_SANDBOX = (): string =>
|
||||
translate(
|
||||
'orcaCloudWorkspace.primaryDelete.noSandbox',
|
||||
"Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces."
|
||||
)
|
||||
|
||||
async function withSandbox(target: Target, run: (sandboxId: string) => Promise<void>) {
|
||||
const sandboxId = await resolveCloudRowSandboxId(target)
|
||||
if (!sandboxId) {
|
||||
toast.error(
|
||||
translate('orcaCloudWorkspace.deleteFailedNamed', 'Couldn’t delete {{name}}', {
|
||||
name: target.displayName ?? ''
|
||||
}),
|
||||
{ description: NO_SANDBOX() }
|
||||
)
|
||||
return
|
||||
}
|
||||
await run(sandboxId)
|
||||
}
|
||||
|
||||
/** Destroys the sandbox (only "destroyed" counts) and refreshes what lists it. */
|
||||
export async function destroyCloudPrimary(target: Target): Promise<void> {
|
||||
const name = target.displayName ?? ''
|
||||
await withSandbox(target, async (sandboxId) => {
|
||||
const result = await window.api.cloudWorkspaces.destroy(sandboxId)
|
||||
if (!result.ok) {
|
||||
toast.error(
|
||||
translate('orcaCloudWorkspace.deleteFailedNamed', 'Couldn’t delete {{name}}', { name }),
|
||||
{ description: describeError(result.error, result.reason, translate) }
|
||||
)
|
||||
return
|
||||
}
|
||||
noteCloudArchiveChanged()
|
||||
void refreshCloudWorktreeBindings().catch(() => undefined)
|
||||
toast.success(translate('orcaCloudWorkspace.deletedNamed', 'Deleted {{name}}', { name }))
|
||||
})
|
||||
}
|
||||
|
||||
export function CloudPrimaryDeleteDialogMount(): React.JSX.Element | null {
|
||||
const target = useSyncExternalStore(subscribeCloudPrimaryDelete, cloudPrimaryDeleteRequest)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const close = (): void => {
|
||||
if (!deleting) {
|
||||
setRequested(null)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && close()}>
|
||||
<DialogContent
|
||||
surface="security"
|
||||
className="max-w-md"
|
||||
data-cloud-primary-delete=""
|
||||
onOpenAutoFocus={(event) => {
|
||||
// Deleting the sandbox can't be undone: focus starts on Cancel, so Enter can't do it.
|
||||
event.preventDefault()
|
||||
cancelRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DeleteCloudWorkspaceHeader worktree={target} dirtyCounts={new Map()} />
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DeleteCloudWorkspaceFooter
|
||||
isDeleting={deleting}
|
||||
cancelButtonRef={cancelRef}
|
||||
onCancel={close}
|
||||
onArchive={() => {
|
||||
setRequested(null)
|
||||
void withSandbox(target, async (sandboxId) => {
|
||||
await archiveSandboxThroughControlPlane(sandboxId, target.displayName ?? '')
|
||||
})
|
||||
}}
|
||||
onDelete={() => {
|
||||
setDeleting(true)
|
||||
void destroyCloudPrimary(target).finally(() => {
|
||||
setDeleting(false)
|
||||
setRequested(null)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// CLOUD-PRIMARY-DELETE: a cloud workspace on its repo's default branch (the sandbox's own checkout,
|
||||
// here paired from another desktop) gets an enabled "Delete workspace…", with no primary-worktree
|
||||
// note and no "Remove Project". A local primary checkout keeps upstream's rule, unchanged.
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import {
|
||||
refreshCloudWorktreeBindings,
|
||||
resetCloudWorkspaceIdentityForTests
|
||||
} from '@/components/cloud-workspaces/cloud-workspace-identity'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
|
||||
const base: Omit<Worktree, 'id' | 'repoId' | 'hostId' | 'path' | 'displayName' | 'branch'> = {
|
||||
instanceId: 'main-instance',
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1
|
||||
}
|
||||
|
||||
function primaryMenu(repo: Repo, worktree: Worktree, pairedEnvironmentId?: string) {
|
||||
return defineFixture({
|
||||
width: 360,
|
||||
store: { repos: [repo], worktreesByRepo: { [repo.id]: [worktree] } },
|
||||
setup: () => {
|
||||
resetCloudWorkspaceIdentityForTests()
|
||||
Object.assign(window.api.cloudWorkspaces, {
|
||||
localWorkspaces: () => Promise.resolve([]),
|
||||
environmentBindings: () =>
|
||||
Promise.resolve(
|
||||
pairedEnvironmentId ? { sbx_paired: { environmentId: pairedEnvironmentId } } : {}
|
||||
)
|
||||
})
|
||||
void refreshCloudWorktreeBindings()
|
||||
},
|
||||
render: () => (
|
||||
<div className="h-[560px] bg-background p-4 text-foreground">
|
||||
<WorktreeContextMenu worktree={worktree}>
|
||||
<div className="text-sm">{worktree.displayName}</div>
|
||||
</WorktreeContextMenu>
|
||||
</div>
|
||||
),
|
||||
interact: async (frame) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
frame.querySelector('[data-worktree-context-menu-scope]')?.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 16,
|
||||
clientY: 40
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Paired cloud workspace on `main`: Delete workspace…, enabled; no "primary" rule, no Remove Project. */
|
||||
export const cloudDefaultBranch = primaryMenu(
|
||||
{
|
||||
id: 'cloud',
|
||||
path: '/workspace/orca-private',
|
||||
displayName: 'orca-private (cloud)',
|
||||
badgeColor: '',
|
||||
addedAt: 1
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 'cloud::/workspace/orca-private',
|
||||
repoId: 'cloud',
|
||||
hostId: 'runtime:env-cloud',
|
||||
path: '/workspace/orca-private',
|
||||
displayName: 'main',
|
||||
branch: 'main'
|
||||
},
|
||||
'env-cloud'
|
||||
)
|
||||
|
||||
/** A local repo's primary checkout: upstream's disabled Delete Worktree and Remove Project, unchanged. */
|
||||
export const localPrimary = primaryMenu(
|
||||
{ id: 'orca', path: '/preview/orca', displayName: 'orca', badgeColor: '', addedAt: 1 },
|
||||
{
|
||||
...base,
|
||||
id: 'orca::/preview/orca',
|
||||
repoId: 'orca',
|
||||
hostId: 'local',
|
||||
path: '/preview/orca',
|
||||
displayName: 'main',
|
||||
branch: 'main'
|
||||
}
|
||||
)
|
||||
+21
-10
@@ -7,11 +7,13 @@ import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown
|
||||
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import type { WorktreeContextMenuModel } from './use-worktree-context-menu-model'
|
||||
import { useIsCloudPrimaryRow, usePrimeCloudRowBindings } from '@/lib/orca-cloud/cloud-host-rows'
|
||||
|
||||
export function WorktreeContextMenuDeleteItems({ model }: { model: WorktreeContextMenuModel }) {
|
||||
const t = useTranslate()
|
||||
const primaryDeleteDescriptionId = useId()
|
||||
const deleteShortcut = useOptionalShortcutLabel('workspace.delete')
|
||||
usePrimeCloudRowBindings()
|
||||
const {
|
||||
batchDeleteWorktrees,
|
||||
contextDeletePending,
|
||||
@@ -20,7 +22,7 @@ export function WorktreeContextMenuDeleteItems({ model }: { model: WorktreeConte
|
||||
handleDelete,
|
||||
isMultiContext,
|
||||
lineageDescendantCount,
|
||||
removesProject,
|
||||
removesProject: modelRemovesProject,
|
||||
worktree
|
||||
} = model
|
||||
const isActiveDeleteTarget = useAppStore((state) => {
|
||||
@@ -32,21 +34,30 @@ export function WorktreeContextMenuDeleteItems({ model }: { model: WorktreeConte
|
||||
worktreeCopyOwnerKey(target.worktree) === worktreeCopyOwnerKey(worktree) &&
|
||||
target.worktree.instanceId === worktree.instanceId
|
||||
})
|
||||
const projectMissing = !isMultiContext && worktree.isMainWorktree && !removesProject
|
||||
// An Orca Cloud workspace's own checkout is deletable: it destroys the sandbox (never "Remove Project").
|
||||
const cloudPrimary = useIsCloudPrimaryRow(worktree) && !isMultiContext
|
||||
const removesProject = !cloudPrimary && modelRemovesProject
|
||||
const projectMissing =
|
||||
!isMultiContext && !cloudPrimary && worktree.isMainWorktree && !removesProject
|
||||
const label = contextDeletePending
|
||||
? t('auto.components.sidebar.WorktreeContextMenu.b42391d8bf', 'Deleting…')
|
||||
: isMultiContext
|
||||
? deleteLabel
|
||||
: folderWorkspaceId
|
||||
? t('auto.components.sidebar.WorktreeContextMenu.250de158fd', 'Remove Workspace')
|
||||
: removesProject
|
||||
? t('auto.components.sidebar.WorktreeContextMenu.f5ac91531d', 'Remove Project from Orca')
|
||||
: lineageDescendantCount > 0
|
||||
: cloudPrimary
|
||||
? t('auto.components.sidebar.WorktreeContextMenu.deleteCloudWorkspace', 'Delete workspace…')
|
||||
: folderWorkspaceId
|
||||
? t('auto.components.sidebar.WorktreeContextMenu.250de158fd', 'Remove Workspace')
|
||||
: removesProject
|
||||
? t(
|
||||
'auto.components.sidebar.WorktreeContextMenu.deleteWithDescendants',
|
||||
'Delete with Descendants…'
|
||||
'auto.components.sidebar.WorktreeContextMenu.f5ac91531d',
|
||||
'Remove Project from Orca'
|
||||
)
|
||||
: t('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')
|
||||
: lineageDescendantCount > 0
|
||||
? t(
|
||||
'auto.components.sidebar.WorktreeContextMenu.deleteWithDescendants',
|
||||
'Delete with Descendants…'
|
||||
)
|
||||
: t('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')
|
||||
return (
|
||||
<>
|
||||
{/* Git cannot remove primary checkouts; keep disabled Delete beside the enabled Remove Project action. */}
|
||||
|
||||
+61
@@ -346,3 +346,64 @@ describe('Delete from Home with a runtime record but no sidebar row (REG-8b)', (
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('@/lib/orca-cloud/cloud-primary-delete-request', () => ({
|
||||
requestCloudPrimaryDelete: vi.fn()
|
||||
}))
|
||||
|
||||
describe('Delete on a cloud workspace on its default branch (CLOUD-PRIMARY-DELETE)', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: false }
|
||||
mocks.state.openModal.mockClear()
|
||||
mocks.state.repos = []
|
||||
const { requestCloudPrimaryDelete } =
|
||||
await import('@/lib/orca-cloud/cloud-primary-delete-request')
|
||||
vi.mocked(requestCloudPrimaryDelete).mockClear()
|
||||
})
|
||||
|
||||
async function pairWith(environmentId: string): Promise<void> {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
cloudWorkspaces: {
|
||||
localWorkspaces: async () => [],
|
||||
environmentBindings: async () => ({ sbx_paired: { environmentId } })
|
||||
}
|
||||
}
|
||||
})
|
||||
const { refreshCloudWorktreeBindings } =
|
||||
await import('@/components/cloud-workspaces/cloud-workspace-identity')
|
||||
await refreshCloudWorktreeBindings()
|
||||
}
|
||||
|
||||
it('a paired cloud workspace is deletable: the cloud delete dialog, never "Remove Project"', async () => {
|
||||
await pairWith('env-cloud')
|
||||
const { runWorktreeDelete } = await import('./delete-worktree-flow')
|
||||
const { requestCloudPrimaryDelete } =
|
||||
await import('@/lib/orca-cloud/cloud-primary-delete-request')
|
||||
setWorktrees([{ id: 'wt-main', isMainWorktree: true, hostId: 'runtime:env-cloud' }])
|
||||
runWorktreeDelete('wt-main', { expectedHostId: 'runtime:env-cloud' })
|
||||
expect(requestCloudPrimaryDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'wt-main', hostId: 'runtime:env-cloud' })
|
||||
)
|
||||
expect(mocks.state.openModal).not.toHaveBeenCalledWith(
|
||||
'confirm-remove-folder',
|
||||
expect.anything()
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('a local primary checkout still cannot be deleted: it offers removing the project', async () => {
|
||||
await pairWith('env-cloud')
|
||||
const { runWorktreeDelete } = await import('./delete-worktree-flow')
|
||||
const { requestCloudPrimaryDelete } =
|
||||
await import('@/lib/orca-cloud/cloud-primary-delete-request')
|
||||
setWorktrees([{ id: 'wt-local-main', isMainWorktree: true }])
|
||||
runWorktreeDelete('wt-local-main')
|
||||
expect(requestCloudPrimaryDelete).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith(
|
||||
'confirm-remove-folder',
|
||||
expect.objectContaining({ repoId: 'repo-1' })
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { runWorktreeDeletesInParallel } from './worktree-delete-execution'
|
||||
import { runWorktreeDeleteWithToast } from './run-worktree-delete-with-toast'
|
||||
import { collectRemovalUnsavedFiles } from '@/components/editor/save/flush/removal-unsaved-editor-files'
|
||||
import { isCloudPrimaryRowNow } from '@/lib/orca-cloud/cloud-host-rows'
|
||||
import { requestCloudPrimaryDelete } from '@/lib/orca-cloud/cloud-primary-delete-request'
|
||||
|
||||
/** Standard Git-worktree deletion funnel; stale record/instance guards precede confirmation preferences. */
|
||||
export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOptions = {}): void {
|
||||
@@ -40,6 +42,11 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
|
||||
}
|
||||
return
|
||||
}
|
||||
// An Orca Cloud workspace's own checkout is the workspace, not a repo's primary: delete the sandbox.
|
||||
if (isCloudPrimaryRowNow(target)) {
|
||||
requestCloudPrimaryDelete(target)
|
||||
return
|
||||
}
|
||||
if (target.isMainWorktree) {
|
||||
const repo = findRepoForHost(state.repos, target.repoId, {
|
||||
hostId: target.hostId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { WorktreeCardSshHostControl } from '../host/WorktreeCardSshHostControl'
|
||||
import { WorktreeCardHostMark } from '../host/WorktreeCardHostMark'
|
||||
import { CloudAgentHeldMark } from '@/components/orca-cloud/CloudAgentHeldMark'
|
||||
import { useIsCloudHostRow } from '@/lib/orca-cloud/cloud-host-rows'
|
||||
import { WorktreeTitleInlineRename } from '../identity/WorktreeTitleInlineRename'
|
||||
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
|
||||
|
||||
@@ -51,6 +52,8 @@ export function WorktreeCardHeader({
|
||||
isFolder,
|
||||
handleWorkspaceQuickAction
|
||||
} = card
|
||||
// "primary" is a local checkout's role; on a cloud row it only means the default branch (design-squad).
|
||||
const cloudRow = useIsCloudHostRow(worktree)
|
||||
const {
|
||||
showPinnedRepoIcon,
|
||||
showInlineRepoBadge,
|
||||
@@ -124,7 +127,7 @@ export function WorktreeCardHeader({
|
||||
onOpen={handleOpenRenameErrorDialog}
|
||||
/>
|
||||
) : null}
|
||||
{!compactCards && worktree.isMainWorktree && !isFolder && (
|
||||
{!compactCards && worktree.isMainWorktree && !isFolder && !cloudRow && (
|
||||
<WorktreeCardPrimaryBadge card={card} hasHoverDetails={hasHoverDetails} />
|
||||
)}
|
||||
|
||||
@@ -141,7 +144,7 @@ export function WorktreeCardHeader({
|
||||
|
||||
{showHeaderActions && (
|
||||
<div className="ml-auto flex shrink-0 items-center justify-center gap-1 pr-1.5">
|
||||
{showTitleRowPrimary && <WorktreeCardPrimaryStar />}
|
||||
{showTitleRowPrimary && !cloudRow && <WorktreeCardPrimaryStar />}
|
||||
|
||||
{showDeleteQuickAction && (
|
||||
<WorktreeCardDeleteQuickAction
|
||||
|
||||
@@ -7540,7 +7540,8 @@
|
||||
"copyNameOption": "Worktree Name",
|
||||
"lineageSubmenu": "Parent & Children",
|
||||
"projectGroupSubmenu": "Project Group",
|
||||
"moveToThisComputer": "Move to this computer"
|
||||
"moveToThisComputer": "Move to this computer",
|
||||
"deleteCloudWorkspace": "Delete workspace…"
|
||||
},
|
||||
"WorktreeList": {
|
||||
"7a8b9c0d1e": "Update required",
|
||||
@@ -27858,6 +27859,9 @@
|
||||
"definition": "Archive keeps a snapshot of this workspace (its files, chats and terminal history) for 30 days, then deletes it. Unarchive it from its menu before then. Like a sleeping workspace, it doesn’t use sandbox hours.",
|
||||
"archivedUntil": "A read-only snapshot of its files, chats and terminal history. Unarchive before {{date}} to open terminals and agents again; after that it’s deleted.",
|
||||
"archived": "A read-only snapshot of its files, chats and terminal history. Unarchive within 30 days of archiving to open terminals and agents again; after that it’s deleted."
|
||||
},
|
||||
"primaryDelete": {
|
||||
"noSandbox": "Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces."
|
||||
}
|
||||
},
|
||||
"cloudAgentSetup": {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Which sidebar rows are Orca Cloud workspaces, including PAIRED ones (CLOUD-PRIMARY-DELETE).
|
||||
// `useIsCloudWorkspace` knows only the workspaces this desktop created. A workspace opened from Home
|
||||
// on another desktop (or after a re-sign-in) is paired: a "<name> (cloud)" server whose rows live on
|
||||
// `runtime:<environmentId>`. The cloud identity store already maps both kinds to their sandbox
|
||||
// (this desktop's runtime records, and the pairing bindings), so this reads it.
|
||||
//
|
||||
// A cloud row on the sandbox's own checkout (isMainWorktree) IS the cloud workspace, not a local
|
||||
// repo's primary checkout: Delete must destroy the sandbox, never "Remove the project".
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
refreshCloudWorktreeBindings,
|
||||
sandboxIdForWorktree,
|
||||
useCloudWorkspaceIdentities
|
||||
} from '@/components/cloud-workspaces/cloud-workspace-identity'
|
||||
import { getCloudWorkspaceSnapshot, useIsCloudWorkspace } from './orca-cloud-workspace-store'
|
||||
|
||||
type Row = { id: string; hostId?: string | null; isMainWorktree?: boolean }
|
||||
|
||||
let primed = false
|
||||
|
||||
/** Loads the bindings once per app session (a menu open refreshes them for new pairings). */
|
||||
export function usePrimeCloudRowBindings(): void {
|
||||
useEffect(() => {
|
||||
if (!primed) {
|
||||
primed = true
|
||||
void refreshCloudWorktreeBindings().catch(() => undefined)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** A row on an Orca Cloud workspace: this desktop's own, or one it paired with. */
|
||||
export function useIsCloudHostRow(row: Row): boolean {
|
||||
const owned = useIsCloudWorkspace(row.id)
|
||||
const identities = useCloudWorkspaceIdentities()
|
||||
return owned || sandboxIdForWorktree(row, identities) !== null
|
||||
}
|
||||
|
||||
/** The cloud workspace itself (the sandbox's own checkout): its Delete destroys the sandbox. */
|
||||
export function useIsCloudPrimaryRow(row: Row): boolean {
|
||||
return useIsCloudHostRow(row) && row.isMainWorktree === true
|
||||
}
|
||||
|
||||
/** Non-React: the same, for the delete funnel. */
|
||||
export function isCloudPrimaryRowNow(row: Row): boolean {
|
||||
return (
|
||||
row.isMainWorktree === true &&
|
||||
(getCloudWorkspaceSnapshot().ids.has(row.id) || sandboxIdForWorktree(row) !== null)
|
||||
)
|
||||
}
|
||||
|
||||
/** The sandbox behind a cloud row, re-reading the bindings first (a pairing may be new). */
|
||||
export async function resolveCloudRowSandboxId(row: Row): Promise<string | null> {
|
||||
await refreshCloudWorktreeBindings().catch(() => undefined)
|
||||
return sandboxIdForWorktree(row)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Which cloud row the cloud delete dialog is open for (CLOUD-PRIMARY-DELETE). Its own module so the
|
||||
// delete funnel can request the dialog without importing the dialog (and the Home flows it uses).
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
|
||||
export type CloudPrimaryDeleteTarget = Pick<
|
||||
Worktree,
|
||||
'id' | 'hostId' | 'displayName' | 'repoId' | 'branch'
|
||||
> &
|
||||
Partial<Pick<Worktree, 'runtimeOwnerEnvironmentId'>>
|
||||
|
||||
let requested: CloudPrimaryDeleteTarget | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export function subscribeCloudPrimaryDelete(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function cloudPrimaryDeleteRequest(): CloudPrimaryDeleteTarget | null {
|
||||
return requested
|
||||
}
|
||||
|
||||
/** Opens (target) or closes (null) the cloud delete dialog. */
|
||||
export function requestCloudPrimaryDelete(target: CloudPrimaryDeleteTarget | null): void {
|
||||
requested = target
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
@@ -950,6 +950,9 @@
|
||||
"localCommands.settings.workspacesTitle",
|
||||
"localCommands.text.tab",
|
||||
"localCommands.text.trailingSpace",
|
||||
"orcaCloudWorkspace.deleteFailedNamed",
|
||||
"orcaCloudWorkspace.deletedNamed",
|
||||
"orcaCloudWorkspace.primaryDelete.noSandbox",
|
||||
"plugins.frameOpenExternal.alwaysAllow",
|
||||
"plugins.frameOpenExternal.cancel",
|
||||
"plugins.frameOpenExternal.message",
|
||||
|
||||
Reference in New Issue
Block a user