fix(repos): deleting a project with live terminals warns and needs --force (#27172)

* fix(repos): deleting a project with live terminals warns and needs --force

orca project setup-delete now refuses a repo-backed project that still has
terminals not known to have exited or saved workspace details, unless --force.
The sidebar remove dialog says how many open terminals will close.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(repos): count a sibling host's host-qualified workspace metadata in the delete guard

Co-Authored-By: Claude <noreply@anthropic.com>

* test(sidebar): cover the remove dialog's open-terminal warning

Co-Authored-By: Claude <noreply@anthropic.com>

* test(sidebar): type the dialog mock state without assertions

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-10 00:44:55 -07:00
committed by GitHub
co-authored by Claude m4air
parent f4c4eaeece
commit cb69d52455
21 changed files with 418 additions and 32 deletions
+2 -1
View File
@@ -208,7 +208,8 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
client,
'projectHostSetup.delete',
{
setupId: getRequiredStringFlag(flags, 'setup')
setupId: getRequiredStringFlag(flags, 'setup'),
force: flags.get('force') === true
}
)
printResult(result, json, formatProjectHostSetupDeleteResult)
+7 -3
View File
@@ -776,7 +776,10 @@ describe('orca cli worktree awareness', () => {
})
})
it('deletes project host setup metadata through the project-first runtime API', async () => {
it.each([
{ flags: [], force: false },
{ flags: ['--force'], force: true }
])('deletes project host setup metadata with force=$force', async ({ flags, force }) => {
queueFixtures(
callMock,
okFixture('req_project_setup_delete', {
@@ -806,10 +809,11 @@ describe('orca cli worktree awareness', () => {
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['project', 'setup-delete', '--setup', 'setup-gpu', '--json'], '/tmp/repo')
await main(['project', 'setup-delete', '--setup', 'setup-gpu', ...flags, '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', {
setupId: 'setup-gpu'
setupId: 'setup-gpu',
force
})
})
})
+1 -1
View File
@@ -74,7 +74,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
' orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
' orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
' orca project setup-delete --setup <setup-id> [--json]',
' orca project setup-delete --setup <setup-id> [--force] [--json]',
' orca repo list [--json]',
' orca repo add --path <path> [--json]',
' orca repo show --repo <selector> [--json]',
+4 -3
View File
@@ -115,11 +115,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
path: ['project', 'setup-delete'],
destructive: true,
summary: 'Remove a project host setup',
usage: 'orca project setup-delete --setup <setup-id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'setup'],
usage: 'orca project setup-delete --setup <setup-id> [--force] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'setup', 'force'],
notes: [
'Independent setups are removed directly.',
'Repo-backed setups remove the registered repo compatibility record.'
'Repo-backed setups remove the registered repo compatibility record.',
'Refuses while the project has open terminals or saved workspace details; --force removes it anyway.'
],
examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json']
}
@@ -45,6 +45,7 @@ import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-c
import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown'
import { countRepoTerminalsNotKnownExited } from './project-removal-impact'
export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers {
protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() =>
@@ -265,7 +266,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
(this as RuntimeCommandSurfaceHost<this>).cloneRepo(url, destination, hostId),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
notifyReposChanged: () => this.notifyReposChanged()
notifyReposChanged: () => this.notifyReposChanged(),
countLiveTerminalsForRepo: (repo) =>
countRepoTerminalsNotKnownExited(repo, this.ptysById.values(), (ptyId) =>
this.isPtyKnownExited(ptyId)
)
})
protected readonly projectGroups = new RuntimeProjectGroupController({
@@ -0,0 +1,150 @@
// `orca project setup-delete` used to drop a repo-backed project even while its terminals were
// running and its workspaces had saved details, orphaning both with no warning.
import { describe, expect, it, vi } from 'vitest'
import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import type { Repo } from '../../shared/repo-types'
import {
countRepoTerminalsNotKnownExited,
countRepoWorkspacesWithMetadata
} from './project-removal-impact'
const repo: Repo = {
id: 'repo-1',
path: '/work/app',
displayName: 'app',
badgeColor: 'blue',
addedAt: 1,
kind: 'git'
}
function makeController(options: {
liveTerminals: number
metaIds: string[]
canonicalMetaIdsForHost?: string[]
targetRepo?: Repo
}) {
const targetRepo = options.targetRepo ?? repo
const { projects, setups: projectHostSetups } = projectHostSetupProjectionFromRepos([targetRepo])
const deleteProjectHostSetup = vi.fn(() => ({
project: projects[0],
setup: projectHostSetups[0],
repo: targetRepo
}))
const store = {
getProjects: () => projects,
getProjectHostSetups: () => projectHostSetups,
getAllWorktreeMeta: () =>
Object.fromEntries(options.metaIds.map((id) => [id, { hostId: undefined }])),
getAllWorktreeMetaForHost: (hostId: string) =>
options.canonicalMetaIdsForHost
? Object.fromEntries(options.canonicalMetaIdsForHost.map((id) => [id, { hostId }]))
: undefined,
deleteProjectHostSetup
}
const controller = new RuntimeProjectHostSetupController({
getStore: () => store as never,
listRepos: () => [targetRepo],
addRepo: vi.fn(),
addRemoteRepo: vi.fn(),
cloneRepo: vi.fn(),
invalidateResolvedWorktrees: vi.fn(),
invalidateWorktreeScan: vi.fn(),
notifyReposChanged: vi.fn(),
countLiveTerminalsForRepo: () => options.liveTerminals
})
return { controller, deleteProjectHostSetup, setupId: projectHostSetups[0].id }
}
describe('project setup delete guard', () => {
it('refuses a CLI delete while terminals are live, and deletes with --force', () => {
const { controller, deleteProjectHostSetup, setupId } = makeController({
liveTerminals: 2,
metaIds: []
})
expect(() => controller.deleteSetup({ setupId, force: false })).toThrow(
/2 terminals still open.*--force/
)
expect(deleteProjectHostSetup).not.toHaveBeenCalled()
controller.deleteSetup({ setupId, force: true })
expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1)
})
it('refuses while workspaces have saved details', () => {
const { controller, deleteProjectHostSetup, setupId } = makeController({
liveTerminals: 0,
metaIds: ['repo-1::/work/app', 'other-repo::/work/other']
})
expect(() => controller.deleteSetup({ setupId, force: false })).toThrow(
/saved details for 1 workspace\./
)
expect(deleteProjectHostSetup).not.toHaveBeenCalled()
})
it('sees sibling-host metadata kept only in the host-qualified maps', () => {
// Same repo id and path on local and SSH: the raw-id map holds only the local row.
const { controller, deleteProjectHostSetup, setupId } = makeController({
liveTerminals: 0,
metaIds: ['repo-1::/work/app'],
canonicalMetaIdsForHost: ['repo-1::/work/app'],
targetRepo: { ...repo, connectionId: 'box' }
})
expect(() => controller.deleteSetup({ setupId, force: false })).toThrow(
/saved details for 1 workspace\./
)
expect(deleteProjectHostSetup).not.toHaveBeenCalled()
})
it('deletes an unused project without --force', () => {
const { controller, deleteProjectHostSetup, setupId } = makeController({
liveTerminals: 0,
metaIds: ['other-repo::/work/other']
})
controller.deleteSetup({ setupId, force: false })
expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1)
})
it('keeps the unchecked delete for older clients that omit force', () => {
const { controller, deleteProjectHostSetup, setupId } = makeController({
liveTerminals: 3,
metaIds: ['repo-1::/work/app']
})
controller.deleteSetup({ setupId })
expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1)
})
})
describe('project removal impact', () => {
it('counts only this repo and host, and treats unverifiable terminals as live', () => {
const ptys = [
{ ptyId: 'local-1', worktreeId: 'repo-1::/work/app', connectionId: null },
{ ptyId: 'local-exited', worktreeId: 'repo-1::/work/app', connectionId: null },
{ ptyId: 'ssh-1', worktreeId: 'repo-1::/work/app', connectionId: 'box' },
{ ptyId: 'other-repo', worktreeId: 'repo-10::/work/app', connectionId: null }
]
const isKnownExited = (ptyId: string) => ptyId === 'local-exited'
expect(countRepoTerminalsNotKnownExited(repo, ptys, isKnownExited)).toBe(1)
expect(
countRepoTerminalsNotKnownExited({ ...repo, connectionId: 'box' }, ptys, isKnownExited)
).toBe(1)
})
it('counts metadata on the repo host only, reading unstamped rows as local', () => {
const meta = {
'repo-1::/work/app': {},
'repo-1::/work/feature': { hostId: 'local' as const },
'repo-1::/srv/app': { hostId: 'ssh:box' as const },
'repo-10::/work/app': {}
}
expect(countRepoWorkspacesWithMetadata(repo, meta)).toBe(2)
expect(countRepoWorkspacesWithMetadata({ ...repo, connectionId: 'box' }, meta)).toBe(1)
})
})
@@ -0,0 +1,73 @@
import type { Repo } from '../../shared/repo-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId
} from '../../shared/execution-host'
import { splitWorktreeId } from '../../shared/worktree/id'
import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence'
export type ProjectRemovalImpact = {
liveTerminals: number
workspacesWithMetadata: number
}
type RepoIdentity = Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
function repoPtyHostFence(repo: RepoIdentity): WorktreePtyHostFence {
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
return host?.kind === 'runtime'
? { resolvedRuntimeEnvironmentId: host.environmentId }
: { resolvedConnectionId: host?.kind === 'ssh' ? host.targetId : null }
}
/** Terminals not known to have exited count: a lost-contact PTY may still be running. */
export function countRepoTerminalsNotKnownExited(
repo: RepoIdentity,
ptys: Iterable<{ ptyId: string; worktreeId: string; connectionId: string | null }>,
isKnownExited: (ptyId: string) => boolean
): number {
const fence = repoPtyHostFence(repo)
let count = 0
for (const pty of ptys) {
if (
splitWorktreeId(pty.worktreeId)?.repoId === repo.id &&
worktreePtyBelongsToHost(pty.ptyId, pty.connectionId, fence) &&
!isKnownExited(pty.ptyId)
) {
count += 1
}
}
return count
}
/** Same host rule as the repo prune: unstamped metadata predates host stamping and is local. */
export function countRepoWorkspacesWithMetadata(
repo: RepoIdentity,
metaById: Record<string, Pick<WorktreeMeta, 'hostId'>>
): number {
const hostId = getRepoExecutionHostId(repo)
const prefix = `${repo.id}::`
return Object.entries(metaById).filter(
([id, meta]) => id.startsWith(prefix) && (meta.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId
).length
}
export function describeProjectRemovalRefusal(impact: ProjectRemovalImpact): string | null {
const parts: string[] = []
if (impact.liveTerminals > 0) {
parts.push(
`${impact.liveTerminals} terminal${impact.liveTerminals === 1 ? '' : 's'} still open`
)
}
if (impact.workspacesWithMetadata > 0) {
parts.push(
`saved details for ${impact.workspacesWithMetadata} workspace${impact.workspacesWithMetadata === 1 ? '' : 's'}`
)
}
if (parts.length === 0) {
return null
}
return `This project has ${parts.join(' and ')}. Removing it detaches those terminals from Orca and deletes the saved workspace details. Re-run with --force to remove it anyway.`
}
@@ -43,7 +43,8 @@ function makeController(): {
cloneRepo,
invalidateResolvedWorktrees: vi.fn(),
invalidateWorktreeScan: vi.fn(),
notifyReposChanged: vi.fn()
notifyReposChanged: vi.fn(),
countLiveTerminalsForRepo: () => 0
})
return {
controller,
@@ -24,6 +24,11 @@ import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-look
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import type { RuntimeStore } from './runtime-store-contract'
import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta'
import {
countRepoWorkspacesWithMetadata,
describeProjectRemovalRefusal
} from './project-removal-impact'
type RuntimeProjectHostSetupDependencies = {
getStore: () => RuntimeStore | null
@@ -40,6 +45,7 @@ type RuntimeProjectHostSetupDependencies = {
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
notifyReposChanged: () => void
countLiveTerminalsForRepo: (repo: Repo) => number
}
// Why clone alone still refuses: nothing in this process clones onto an SSH host. `cloneRepo` runs
@@ -147,6 +153,9 @@ export class RuntimeProjectHostSetupController {
if (!store?.deleteProjectHostSetup) {
throw new Error('runtime_unavailable')
}
if (args.force === false) {
this.assertSetupRemovalIsUnused(store, args.setupId)
}
const result = store.deleteProjectHostSetup(args)
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
@@ -154,6 +163,33 @@ export class RuntimeProjectHostSetupController {
return result
}
// Why only for an explicit `force: false`: older clients omit it and must keep their delete.
private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void {
const setup = this.listSetups().find((entry) => entry.id === setupId)
// Same exact-host match the store's delete uses, so a sibling host's row is never measured.
const repo = setup?.repoId
? this.deps
.listRepos()
.find(
(entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId
)
: undefined
if (!repo) {
return
}
const refusal = describeProjectRemovalRefusal({
liveTerminals: this.deps.countLiveTerminalsForRepo(repo),
workspacesWithMetadata: countRepoWorkspacesWithMetadata(
repo,
// Host-qualified read: a same-id, same-path sibling host keeps its rows only in canonical maps.
readAllWorktreeMetaForRepo(store, repo)
)
})
if (refusal) {
throw new Error(refusal)
}
}
private completeSetup(
args: ProjectHostSetupExistingFolderArgs,
initialRepo: Repo,
@@ -3,21 +3,30 @@ import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
const mocks = vi.hoisted(() => ({
state: {
activeModal: 'confirm-remove-folder' as string | null,
modalData: {
repoId: 'repo-1',
displayName: 'Example',
hostId: 'ssh:target-1'
} as Record<string, unknown>,
repos: [] as Repo[],
sshTargetLabels: new Map<string, string>(),
removedSshTargetLabels: new Map<string, string>(),
closeModal: vi.fn(),
removeProject: vi.fn()
const mocks = vi.hoisted(() => {
const worktreesByRepo: Record<string, { id: string; hostId?: string }[]> = {}
const tabsByWorktree: Record<string, { id: string }[]> = {}
const ptyIdsByTabId: Record<string, string[]> = {}
return {
state: {
activeModal: 'confirm-remove-folder' as string | null,
modalData: {
repoId: 'repo-1',
displayName: 'Example',
hostId: 'ssh:target-1'
} as Record<string, unknown>,
repos: [] as Repo[],
sshTargetLabels: new Map<string, string>(),
removedSshTargetLabels: new Map<string, string>(),
worktreesByRepo,
detectedWorktreesByRepo: {},
tabsByWorktree,
ptyIdsByTabId,
closeModal: vi.fn(),
removeProject: vi.fn()
}
}
}))
})
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
@@ -70,6 +79,9 @@ describe('RemoveFolderDialog', () => {
}
mocks.state.sshTargetLabels = new Map([['target-1', 'Persistent host']])
mocks.state.removedSshTargetLabels = new Map()
mocks.state.worktreesByRepo = {}
mocks.state.tabsByWorktree = {}
mocks.state.ptyIdsByTabId = {}
})
it('warns that VM recipe cleanup controls file deletion', () => {
@@ -91,4 +103,17 @@ describe('RemoveFolderDialog', () => {
expect(html).toContain('Its files stay on Persistent host')
expect(html).not.toContain('VM recipe')
})
it('says how many open terminals removal will close', () => {
mocks.state.repos = [repo('target-1', 'ssh:target-1')]
mocks.state.worktreesByRepo = {
'repo-1': [{ id: 'repo-1::/workspace/example', hostId: 'ssh:target-1' }]
}
mocks.state.tabsByWorktree = { 'repo-1::/workspace/example': [{ id: 'a' }, { id: 'b' }] }
mocks.state.ptyIdsByTabId = { a: ['pty-a'], b: ['pty-b'] }
expect(renderToStaticMarkup(<RemoveFolderDialog />)).toContain(
'2 open terminals in this project will be closed.'
)
})
})
@@ -15,6 +15,7 @@ import {
isRuntimeOwnedSshTargetId,
normalizeExecutionHostId
} from '../../../../shared/execution-host'
import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal'
// Why: interpolated into the sentence so locales control where the name sits;
// U+0000 cannot appear in a real project name, so the split is unambiguous.
@@ -53,6 +54,23 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
)
})
const openTerminalCount = useAppStore((s) =>
repoId && hostId ? countOpenRepoTerminalTabs(s, repoId, hostId) : 0
)
const openTerminalWarning =
openTerminalCount === 0
? null
: openTerminalCount === 1
? translate(
'auto.components.sidebar.RemoveFolderDialog.openTerminalWarningOne',
'1 open terminal in this project will be closed.'
)
: translate(
'auto.components.sidebar.RemoveFolderDialog.openTerminalWarningMany',
'{{count}} open terminals in this project will be closed.',
{ count: openTerminalCount }
)
// Why: fragment concatenation around the styled name cannot be reordered by
// SOV locales (#9294). Translate one full sentence with the name as a
// sentinel token, then split on it to re-apply the inline emphasis.
@@ -104,6 +122,9 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
<span className="break-all font-medium text-foreground">{displayName}</span>
{descriptionAfterName}
</DialogDescription>
{openTerminalWarning ? (
<p className="text-xs font-medium text-foreground">{openTerminalWarning}</p>
) : null}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
+3 -1
View File
@@ -5569,7 +5569,9 @@
"b79b39d865": "Remove Project",
"removeDescriptionSsh": "This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.",
"removeDescriptionLocal": "This only removes {{name}} from Orca. It is still on your disk.",
"removeDescriptionVmRecipe": "This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted."
"removeDescriptionVmRecipe": "This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.",
"openTerminalWarningOne": "1 open terminal in this project will be closed.",
"openTerminalWarningMany": "{{count}} open terminals in this project will be closed."
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "Reveal active workspace"
+3 -1
View File
@@ -5318,7 +5318,9 @@
"b79b39d865": "Quitar proyecto",
"removeDescriptionSsh": "Esto solo elimina {{name}} de Orca. Sus archivos permanecen en {{host}}; vuelve a añadir ese host SSH para recuperarlo.",
"removeDescriptionLocal": "Esto solo elimina {{name}} de Orca. Sigue estando en tu disco.",
"removeDescriptionVmRecipe": "Esto elimina {{name}} de Orca. Su receta de VM determina si el entorno y sus archivos se eliminan permanentemente."
"removeDescriptionVmRecipe": "Esto elimina {{name}} de Orca. Su receta de VM determina si el entorno y sus archivos se eliminan permanentemente.",
"openTerminalWarningOne": "Se cerrará 1 terminal abierto en este proyecto.",
"openTerminalWarningMany": "Se cerrarán {{count}} terminales abiertos en este proyecto."
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "Mostrar espacio de trabajo activo"
+3 -1
View File
@@ -5439,7 +5439,9 @@
"b79b39d865": "Retirer le projet",
"removeDescriptionSsh": "Ceci retire uniquement {{name}} d'Orca. Ses fichiers restent sur {{host}} — rajoutez cet hôte SSH pour le récupérer.",
"removeDescriptionLocal": "Ceci retire uniquement {{name}} d'Orca. Il est toujours sur votre disque.",
"removeDescriptionVmRecipe": "Ceci retire {{name}} d'Orca. Sa recette de VM détermine si l'environnement et ses fichiers sont définitivement supprimés."
"removeDescriptionVmRecipe": "Ceci retire {{name}} d'Orca. Sa recette de VM détermine si l'environnement et ses fichiers sont définitivement supprimés.",
"openTerminalWarningOne": "1 terminal ouvert dans ce projet sera fermé.",
"openTerminalWarningMany": "{{count}} terminaux ouverts dans ce projet seront fermés."
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "Révéler l'espace de travail actif"
+3 -1
View File
@@ -5299,7 +5299,9 @@
"b79b39d865": "プロジェクトの削除",
"removeDescriptionSsh": "この操作は {{name}} を Orca から削除するだけです。ファイルは {{host}} に残っており、その SSH ホストを再追加すれば復元できます。",
"removeDescriptionLocal": "この操作は {{name}} を Orca から削除するだけです。ファイルはディスクに残っています。",
"removeDescriptionVmRecipe": "これにより、{{name}} が Orca から削除されます。その VM レシピによって、環境とそのファイルが完全に削除されるかどうかが決まります。"
"removeDescriptionVmRecipe": "これにより、{{name}} が Orca から削除されます。その VM レシピによって、環境とそのファイルが完全に削除されるかどうかが決まります。",
"openTerminalWarningOne": "このプロジェクトで開いているターミナル 1 個が閉じられます。",
"openTerminalWarningMany": "このプロジェクトで開いているターミナル {{count}} 個が閉じられます。"
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "アクティブなワークスペースを表示する"
+3 -1
View File
@@ -5299,7 +5299,9 @@
"b79b39d865": "프로젝트 제거",
"removeDescriptionSsh": "이 작업은 {{name}}을(를) Orca에서 제거만 합니다. 파일은 {{host}}에 그대로 남아 있으며, 해당 SSH 호스트를 다시 추가하면 복구할 수 있습니다.",
"removeDescriptionLocal": "이 작업은 {{name}}을(를) Orca에서 제거만 합니다. 파일은 디스크에 그대로 남아 있습니다.",
"removeDescriptionVmRecipe": "이렇게 하면 Orca에서 {{name}}이(가) 제거됩니다. 해당 VM 레시피에 따라 환경과 해당 파일이 영구적으로 삭제되는지 여부가 결정됩니다."
"removeDescriptionVmRecipe": "이렇게 하면 Orca에서 {{name}}이(가) 제거됩니다. 해당 VM 레시피에 따라 환경과 해당 파일이 영구적으로 삭제되는지 여부가 결정됩니다.",
"openTerminalWarningOne": "이 프로젝트에서 열려 있는 터미널 1개가 닫힙니다.",
"openTerminalWarningMany": "이 프로젝트에서 열려 있는 터미널 {{count}}개가 닫힙니다."
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "활성 워크스페이스로 이동"
+3 -1
View File
@@ -5299,7 +5299,9 @@
"b79b39d865": "删除项目",
"removeDescriptionSsh": "此操作仅将 {{name}} 从 Orca 中移除。文件仍保留在 {{host}} 上 — 重新添加该 SSH 主机即可恢复。",
"removeDescriptionLocal": "此操作仅将 {{name}} 从 Orca 中移除。文件仍保留在您的磁盘上。",
"removeDescriptionVmRecipe": "这会从 Orca 中删除 {{name}}。其VM配方决定是否永久删除环境及其文件。"
"removeDescriptionVmRecipe": "这会从 Orca 中删除 {{name}}。其VM配方决定是否永久删除环境及其文件。",
"openTerminalWarningOne": "此项目中 1 个打开的终端将被关闭。",
"openTerminalWarningMany": "此项目中 {{count}} 个打开的终端将被关闭。"
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "显示活动工作区"
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { countOpenRepoTerminalTabs } from './repo-removal'
describe('countOpenRepoTerminalTabs', () => {
it('counts tabs with a PTY on the removed host only', () => {
const state = {
worktreesByRepo: {
'repo-1': [
{ id: 'repo-1::/a', hostId: 'local' },
{ id: 'repo-1::/srv/a', hostId: 'ssh:box' }
]
},
detectedWorktreesByRepo: {},
tabsByWorktree: {
'repo-1::/a': [{ id: 'tab-live' }, { id: 'tab-no-pty' }],
'repo-1::/srv/a': [{ id: 'tab-ssh' }]
},
ptyIdsByTabId: { 'tab-live': ['pty-1'], 'tab-no-pty': [], 'tab-ssh': ['pty-2'] }
}
expect(countOpenRepoTerminalTabs(state, 'repo-1', 'local')).toBe(1)
expect(countOpenRepoTerminalTabs(state, 'repo-1', 'ssh:box')).toBe(1)
expect(countOpenRepoTerminalTabs(state, 'repo-2', 'local')).toBe(0)
})
})
+30 -1
View File
@@ -32,8 +32,17 @@ export function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: str
return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId
}
type HostScopedWorktreeRef = { id: string; hostId?: string }
type RepoWorktreeCatalog = {
worktreesByRepo: Readonly<Record<string, readonly HostScopedWorktreeRef[] | undefined>>
detectedWorktreesByRepo: Readonly<
Record<string, { worktrees: readonly HostScopedWorktreeRef[] } | undefined>
>
}
export function getKnownRepoWorktreeIds(
state: AppState,
state: RepoWorktreeCatalog,
projectId: string,
hostId?: string
): string[] {
@@ -51,6 +60,26 @@ export function getKnownRepoWorktreeIds(
return [...ids]
}
/** Terminal tabs that removing this project's host row will close. */
export function countOpenRepoTerminalTabs(
state: RepoWorktreeCatalog & {
tabsByWorktree: Readonly<Record<string, readonly { id: string }[] | undefined>>
ptyIdsByTabId: Readonly<Record<string, readonly string[] | undefined>>
},
projectId: string,
hostId: string
): number {
let count = 0
for (const worktreeId of getKnownRepoWorktreeIds(state, projectId, hostId)) {
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
if ((state.ptyIdsByTabId[tab.id]?.length ?? 0) > 0) {
count += 1
}
}
}
return count
}
export function createRepoRemovalActions(
set: Parameters<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
+2
View File
@@ -116,6 +116,8 @@ export type ProjectHostSetupUpdateArgs = {
export type ProjectHostSetupDeleteArgs = {
setupId: string
/** `false` refuses while the project has live terminals or workspace metadata; absent skips the check. */
force?: boolean
}
export type ProjectHostSetupResult = {
@@ -91,5 +91,6 @@ export const ProjectHostSetupUpdate = z.object({
})
export const ProjectHostSetupDelete = z.object({
setupId: requiredString('Missing setup ID')
setupId: requiredString('Missing setup ID'),
force: z.boolean().optional()
})