mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "アクティブなワークスペースを表示する"
|
||||
|
||||
@@ -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": "활성 워크스페이스로 이동"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user