diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c713bc63c6c..0626a79d9c6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts index 8e7ffd913d8..33f40878a70 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts @@ -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 { + return deps.archiveWithUndo(`home:${sandboxId}`, { + name, + commit: async () => asResult(await deps.patchArchived(sandboxId, true)) + }) +} + export async function archiveFromHome( w: CloudWorkspace, name: string, diff --git a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts new file mode 100644 index 00000000000..9ac70eda62d --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.test.ts @@ -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, + bindings: Record +) { + 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." + }) + }) +}) diff --git a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx new file mode 100644 index 00000000000..aa5136e8484 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx @@ -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) { + 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 { + 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(null) + if (!target) { + return null + } + const close = (): void => { + if (!deleting) { + setRequested(null) + } + } + return ( + !open && close()}> + { + // Deleting the sandbox can't be undone: focus starts on Cancel, so Enter can't do it. + event.preventDefault() + cancelRef.current?.focus() + }} + > + + + + + { + setRequested(null) + void withSandbox(target, async (sandboxId) => { + await archiveSandboxThroughControlPlane(sandboxId, target.displayName ?? '') + }) + }} + onDelete={() => { + setDeleting(true) + void destroyCloudPrimary(target).finally(() => { + setDeleting(false) + setRequested(null) + }) + }} + /> + + + + ) +} diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuCloudPrimary.fixture.tsx b/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuCloudPrimary.fixture.tsx new file mode 100644 index 00000000000..23c7cb8b2ca --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuCloudPrimary.fixture.tsx @@ -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 = { + 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: () => ( +
+ +
{worktree.displayName}
+
+
+ ), + 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' + } +) diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuDeleteItems.tsx b/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuDeleteItems.tsx index be706601875..35a28f95e27 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuDeleteItems.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuDeleteItems.tsx @@ -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. */} diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.test.ts index c705a900838..0823436e6c0 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.ts index fffde41c774..a7ff702d46c 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-flow.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx index d071f00ac30..bbc1c15720b 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-header.tsx @@ -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 && ( )} @@ -141,7 +144,7 @@ export function WorktreeCardHeader({ {showHeaderActions && (
- {showTitleRowPrimary && } + {showTitleRowPrimary && !cloudRow && } {showDeleteQuickAction && ( (cloud)" server whose rows live on +// `runtime:`. 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 { + await refreshCloudWorktreeBindings().catch(() => undefined) + return sandboxIdForWorktree(row) +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-primary-delete-request.ts b/src/renderer/src/lib/orca-cloud/cloud-primary-delete-request.ts new file mode 100644 index 00000000000..81a126429db --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-primary-delete-request.ts @@ -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> + +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()) +} diff --git a/src/shared/plugins/languages/protected-copy-keys.json b/src/shared/plugins/languages/protected-copy-keys.json index f2fad793162..741d48cb6f8 100644 --- a/src/shared/plugins/languages/protected-copy-keys.json +++ b/src/shared/plugins/languages/protected-copy-keys.json @@ -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",