mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): offer Remove from Orca for SSH workspaces this app cannot delete (#35271)
* fix(ssh): offer Remove from Orca for SSH workspaces this app cannot delete Refs #34677 * fix(ssh): retire delete-remote copy for unserved SSH workspaces Refs #34677 * fix(ssh): retire delete-remote copy in the German catalog Refs #34677 * fix(ssh): drop a stale host-removal comment Refs #34677 * test(ssh): pin that an unserved SSH twin is only forgotten Refs #34677 * test(ssh): build the unserved-twin store without a cast Refs #34677 * test(ssh): type the unserved-twin store state Refs #34677
This commit is contained in:
@@ -3533,9 +3533,6 @@
|
||||
"auto.components.sidebar.DeleteWorktreeWarningPanels.c4f96a6e18": {
|
||||
"ko": "메인 워크트리"
|
||||
},
|
||||
"auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody": {
|
||||
"ko": "이 워크스페이스의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요."
|
||||
},
|
||||
"auto.components.sidebar.HostRemoveDialog.1a2b3c4d5e": {
|
||||
"ko": "{{value0}} 제거됨"
|
||||
},
|
||||
|
||||
@@ -389,7 +389,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
worktreeId: `${repo.id}::${localPath}`,
|
||||
force: true
|
||||
})
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
).rejects.toThrow('isn’t served by Orca’s server')
|
||||
|
||||
await expect(lstat(localPath)).resolves.toBeTruthy()
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
|
||||
@@ -7,7 +7,10 @@ import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/re
|
||||
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
|
||||
import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree'
|
||||
import { SshHostNotServedError } from '../../../providers/ssh-host-not-served-error'
|
||||
import {
|
||||
SshHostNotServedError,
|
||||
SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE
|
||||
} from '../../../providers/ssh-host-not-served-error'
|
||||
import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner'
|
||||
import {
|
||||
authorizeUnregisteredWindowsRemoval,
|
||||
@@ -54,7 +57,7 @@ export async function executeWorktreeRemoval(
|
||||
}
|
||||
assertRemovalHostMatchesRepoRow(repo, repoId, removalHostId)
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId)
|
||||
throw new SshHostNotServedError(repo.connectionId, SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE)
|
||||
}
|
||||
const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-ty
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { listWorktreesStrict } from '../../../git/worktree'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
|
||||
import { SshHostNotServedError } from '../../../providers/ssh-host-not-served-error'
|
||||
import {
|
||||
SshHostNotServedError,
|
||||
SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE
|
||||
} from '../../../providers/ssh-host-not-served-error'
|
||||
import { resolveWorktreeRemovalHomeForHost } from '../../../worktree-removal-execution-host-route'
|
||||
import {
|
||||
getNestedWorktreeRemovalPlan,
|
||||
@@ -26,7 +29,7 @@ export async function previewNestedWorktreeRemoval(
|
||||
}
|
||||
assertRemovalHostMatchesRepoRow(repo, repo.id, hostId)
|
||||
if (repo.connectionId) {
|
||||
throw new SshHostNotServedError(repo.connectionId)
|
||||
throw new SshHostNotServedError(repo.connectionId, SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE)
|
||||
}
|
||||
const worktrees = await listWorktreesStrict(
|
||||
repo.path,
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
export const SSH_HOST_NOT_SERVED_MESSAGE =
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
|
||||
/** Reconnecting cannot help: only the host's Orca server could delete there, and it lacks this project. */
|
||||
export const SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE =
|
||||
'Orca can’t delete this workspace on its SSH host because its project isn’t served by Orca’s server there. Remove it from Orca instead; files on the host are left untouched.'
|
||||
|
||||
export const SSH_GIT_RECONNECT_MESSAGE =
|
||||
'SSH Git provider is not available. Reconnect to this target and try again.'
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
).rejects.toThrow('isn’t served by Orca’s server')
|
||||
|
||||
await expect(lstat(localPath)).resolves.toBeTruthy()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:target-a'
|
||||
})
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
).rejects.toThrow('isn’t served by Orca’s server')
|
||||
|
||||
expect(listWorktreesStrict).not.toHaveBeenCalled()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
ExecutionHostNotDispatchableError,
|
||||
resolveGitRouteForHost
|
||||
} from './providers/execution-host-provider-dispatch'
|
||||
import { SshHostNotServedError } from './providers/ssh-host-not-served-error'
|
||||
import {
|
||||
SshHostNotServedError,
|
||||
SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE
|
||||
} from './providers/ssh-host-not-served-error'
|
||||
|
||||
export type WorktreeRemovalRoute = { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID }
|
||||
|
||||
@@ -26,7 +29,7 @@ export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRe
|
||||
case 'runtime':
|
||||
throw new ExecutionHostNotDispatchableError(route.hostId)
|
||||
case 'ssh':
|
||||
throw new SshHostNotServedError(route.connectionId)
|
||||
throw new SshHostNotServedError(route.connectionId, SSH_WORKTREE_REMOVAL_NOT_SERVED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,8 +177,7 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
|
||||
const resolution = resolveSshHostRemoval({
|
||||
targetId: target.id,
|
||||
repos: useAppStore.getState().repos,
|
||||
worktrees: getAllWorktreesFromState(useAppStore.getState()),
|
||||
sshConnectionStates: useAppStore.getState().sshConnectionStates
|
||||
worktrees: getAllWorktreesFromState(useAppStore.getState())
|
||||
})
|
||||
if (resolution.workspaceCount > 0) {
|
||||
setHostRemoveTarget({ targetId: target.id, label: target.label })
|
||||
|
||||
@@ -80,8 +80,7 @@ export function useSshBulkRemove(args: {
|
||||
const resolution = resolveSshHostRemoval({
|
||||
targetId: target.id,
|
||||
repos: state.repos,
|
||||
worktrees,
|
||||
sshConnectionStates: state.sshConnectionStates
|
||||
worktrees
|
||||
})
|
||||
return resolution.workspaceCount > 0 ? 'workspaces' : null
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { RemoveWorktreeOptions } from '@/store/slices/worktree-removal-options'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const allWorktrees: Worktree[] = []
|
||||
@@ -21,7 +20,7 @@ const mocks = vi.hoisted(() => {
|
||||
gitStatusByWorktree: {},
|
||||
deleteStateByWorktreeId: {}
|
||||
}
|
||||
return { state, connect: vi.fn() }
|
||||
return { state }
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
@@ -60,10 +59,6 @@ vi.mock('./removal-unsaved-files', () => ({ blockRemovalForUnsavedFiles: () => f
|
||||
vi.mock('@/store/slices/worktrees/teardown/removed-workspace-unsaved-files', () => ({
|
||||
confirmUnsavedDiscard: () => new Map()
|
||||
}))
|
||||
vi.mock('./delete-worktree-flow', async () => ({
|
||||
runWorktreeDeleteWithToast: (await import('./run-worktree-delete-with-toast'))
|
||||
.runWorktreeDeleteWithToast
|
||||
}))
|
||||
|
||||
import ForgetSshWorkspaceDialog from './ForgetSshWorkspaceDialog'
|
||||
|
||||
@@ -93,63 +88,34 @@ function sshWorkspace(instanceId: string): Worktree {
|
||||
}
|
||||
}
|
||||
|
||||
function replaceWorkspace(): void {
|
||||
mocks.state.allWorktrees.splice(0, 1, sshWorkspace('instance-B'))
|
||||
}
|
||||
|
||||
describe('ForgetSshWorkspaceDialog Reconnect & Delete (STA-11912)', () => {
|
||||
describe('ForgetSshWorkspaceDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.allWorktrees.splice(0, mocks.state.allWorktrees.length, sshWorkspace('instance-A'))
|
||||
mocks.state.modalData = {
|
||||
worktreeId: 'repo::/srv/wt',
|
||||
worktreeDeleteIdentities: [{ id: 'repo::/srv/wt', instanceId: 'instance-A', hostId }],
|
||||
displayName: 'wt',
|
||||
resolution: { kind: 'disconnected', targetId: 'box' }
|
||||
resolution: { kind: 'saved', targetId: 'box' }
|
||||
}
|
||||
mocks.state.removeWorktree.mockReset()
|
||||
mocks.state.removeWorktree.mockReset().mockResolvedValue({ ok: true })
|
||||
mocks.state.closeModal.mockReset()
|
||||
mocks.state.clearWorktreeDeleteState.mockReset()
|
||||
mocks.connect.mockReset()
|
||||
vi.stubGlobal('api', { ssh: { connect: mocks.connect } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('refuses when a replacement appears at the same host and id during reconnect', async () => {
|
||||
mocks.connect.mockImplementation(async () => replaceWorkspace())
|
||||
it('forgets a saved host’s workspace on its SSH host without a remote delete', async () => {
|
||||
render(<ForgetSshWorkspaceDialog />)
|
||||
|
||||
fireEvent.click(screen.getByText('Reconnect & Delete'))
|
||||
fireEvent.click(screen.getByText('Remove from Orca'))
|
||||
|
||||
await waitFor(() => expect(mocks.state.closeModal).toHaveBeenCalled())
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-checks the selected incarnation immediately before the destructive send', async () => {
|
||||
mocks.connect.mockResolvedValue(undefined)
|
||||
let dispatchError: unknown = null
|
||||
mocks.state.removeWorktree.mockImplementation(
|
||||
async (_target: unknown, _force: boolean, options?: RemoveWorktreeOptions) => {
|
||||
replaceWorkspace()
|
||||
try {
|
||||
options?.assertBeforeDispatch?.()
|
||||
} catch (error) {
|
||||
dispatchError = error
|
||||
return { ok: false, error: String(error) }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledWith(
|
||||
{ id: 'repo::/srv/wt', executionHostId: hostId },
|
||||
false,
|
||||
expect.objectContaining({ mode: 'forget-local' })
|
||||
)
|
||||
render(<ForgetSshWorkspaceDialog />)
|
||||
|
||||
fireEvent.click(screen.getByText('Reconnect & Delete'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('repo::/srv/wt', hostId)
|
||||
)
|
||||
expect(dispatchError).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { runWorktreeDeleteWithToast } from './delete-worktree-flow'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
|
||||
import type { SshWorkspaceForgetResolution } from './ssh-workspace-forget-resolution'
|
||||
@@ -53,7 +52,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
// Retain removed-host labels for ghost workspaces before falling back to raw IDs.
|
||||
return s.sshTargetLabels.get(targetId) ?? s.removedSshTargetLabels.get(targetId) ?? targetId
|
||||
})
|
||||
const [busy, setBusy] = useState<null | 'reconnect' | 'forget'>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const unsavedTargets = useMemo(
|
||||
() =>
|
||||
@@ -72,7 +71,6 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
return null
|
||||
}
|
||||
const { worktreeId, displayName, resolution } = modalData
|
||||
const canReconnect = resolution.kind === 'disconnected'
|
||||
// This workspace’s named SSH target is the host confirmed for removal.
|
||||
const removalTarget: WorktreeRemovalTarget = {
|
||||
id: worktreeId,
|
||||
@@ -95,73 +93,18 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
const refuseReplacedSelection = (): void => {
|
||||
showWorkspaceListChangedToast()
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
setBusy(false)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
|
||||
const done = (): void => {
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
setBusy(false)
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReconnectAndDelete = async (): Promise<void> => {
|
||||
if (resolution.kind !== 'disconnected') {
|
||||
return
|
||||
}
|
||||
if (!isSelectedWorkspaceCurrent()) {
|
||||
refuseReplacedSelection()
|
||||
return
|
||||
}
|
||||
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
|
||||
setBusy('reconnect')
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId: resolution.targetId })
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
}
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.reconnectFailed',
|
||||
'Reconnection failed'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!isSelectedWorkspaceCurrent()) {
|
||||
refuseReplacedSelection()
|
||||
return
|
||||
}
|
||||
// Close before the delete toast to avoid overlapping confirmations.
|
||||
closeModal()
|
||||
void runWorktreeDeleteWithToast(removalTarget, displayName, {
|
||||
confirmedDiscards,
|
||||
assertBeforeDispatch: () => {
|
||||
if (!isSelectedWorkspaceCurrent()) {
|
||||
throw new Error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.workspaceListChanged',
|
||||
'Workspace list changed'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}).then((deleted) => {
|
||||
// Why: the refusal's error state is keyed by id and host, which the replacement row shares.
|
||||
if (!deleted && !isSelectedWorkspaceCurrent() && removalTarget.executionHostId) {
|
||||
useAppStore.getState().clearWorktreeDeleteState(worktreeId, removalTarget.executionHostId)
|
||||
}
|
||||
})
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Forget only Orca’s records; remote files, worktrees and branches survive.
|
||||
const handleForget = async (): Promise<void> => {
|
||||
if (!isSelectedWorkspaceCurrent()) {
|
||||
@@ -169,7 +112,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
return
|
||||
}
|
||||
const confirmedDiscards = confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
|
||||
setBusy('forget')
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await useAppStore
|
||||
.getState()
|
||||
@@ -177,7 +120,7 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
if (!result.ok) {
|
||||
toast.error(result.error)
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
setBusy(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -185,17 +128,11 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err))
|
||||
if (mountedRef.current) {
|
||||
setBusy(null)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const forgetDescription = translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.forgetBody',
|
||||
'Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.',
|
||||
{ host: hostLabel }
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => (!open ? closeModal() : undefined)}>
|
||||
<DialogContent surface="security" className="sm:max-w-md" showCloseButton={false}>
|
||||
@@ -203,18 +140,17 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<ServerOff className="size-4 text-muted-foreground" />
|
||||
{translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.title',
|
||||
'Delete “{{name}}”?',
|
||||
{
|
||||
name: displayName
|
||||
}
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.removeTitle',
|
||||
'Remove “{{name}}” from Orca?',
|
||||
{ name: displayName }
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{canReconnect
|
||||
{resolution.kind === 'saved'
|
||||
? translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.disconnectedBody',
|
||||
'The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.'
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.savedHostBody',
|
||||
'This workspace’s project isn’t served by Orca’s server on {{host}}, so Orca can’t delete it there. You can remove it from Orca; files, the Git worktree, and branches on {{host}} are left untouched.',
|
||||
{ host: hostLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.ghostBody',
|
||||
@@ -231,40 +167,17 @@ export function ForgetSshWorkspaceDialog(): React.JSX.Element | null {
|
||||
|
||||
<RemovalUnsavedFilesNotice targets={unsavedTargets} enabled />
|
||||
|
||||
{/* Ghost-host copy already promises files survive; repeat only on the reconnect path. */}
|
||||
{canReconnect ? (
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">{forgetDescription}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => closeModal()} disabled={busy != null}>
|
||||
<Button variant="outline" size="sm" onClick={() => closeModal()} disabled={busy}>
|
||||
{translate('auto.components.sidebar.ForgetSshWorkspaceDialog.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleForget()}
|
||||
disabled={busy != null}
|
||||
>
|
||||
{busy === 'forget' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
<Button size="sm" onClick={() => void handleForget()} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.forget',
|
||||
'Remove from Orca'
|
||||
)}
|
||||
</Button>
|
||||
{canReconnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleReconnectAndDelete()}
|
||||
disabled={busy != null}
|
||||
>
|
||||
{busy === 'reconnect' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{translate(
|
||||
'auto.components.sidebar.ForgetSshWorkspaceDialog.reconnectAndDelete',
|
||||
'Reconnect & Delete'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -55,7 +55,6 @@ export function HostRemoveDialog({
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s))
|
||||
const detectedWorktreesByRepo = useAppStore((s) => selectDetectedWorktreesByRepo(s))
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
|
||||
const sshResolution = useMemo(() => {
|
||||
if (target.kind !== 'ssh') {
|
||||
@@ -64,10 +63,9 @@ export function HostRemoveDialog({
|
||||
return resolveSshHostRemoval({
|
||||
targetId: target.targetId,
|
||||
repos,
|
||||
worktrees: getAllWorktreesFromState({ worktreesByRepo }),
|
||||
sshConnectionStates
|
||||
worktrees: getAllWorktreesFromState({ worktreesByRepo })
|
||||
})
|
||||
}, [target, repos, worktreesByRepo, sshConnectionStates])
|
||||
}, [target, repos, worktreesByRepo])
|
||||
|
||||
const unsavedTargets = useMemo(() => {
|
||||
if (!sshResolution || !deleteWorkspaces) {
|
||||
@@ -85,7 +83,6 @@ export function HostRemoveDialog({
|
||||
|
||||
const workspaceCount = sshResolution?.workspaceCount ?? 0
|
||||
const hasWorkspaces = workspaceCount > 0
|
||||
const isConnected = sshResolution?.isConnected ?? false
|
||||
|
||||
// Drop orphaned label overrides so reused host IDs cannot inherit stale names.
|
||||
const dropOverridesForHost = (): void => {
|
||||
@@ -120,7 +117,6 @@ export function HostRemoveDialog({
|
||||
if (target.kind === 'ssh' && deleteWorkspaces && sshResolution) {
|
||||
const { failedIds } = await clearSshHostWorkspaces(
|
||||
sshResolution,
|
||||
isConnected ? 'delete-remote' : 'forget-local',
|
||||
confirmUnsavedDiscard(useAppStore.getState, unsavedTargets)
|
||||
)
|
||||
// Keep the host if workspace cleanup fails so stranded rows remain reachable for retry.
|
||||
@@ -202,18 +198,11 @@ export function HostRemoveDialog({
|
||||
'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.'
|
||||
)
|
||||
|
||||
// Connected hosts delete remote files; offline/ghost hosts only forget local records.
|
||||
const deleteOptionLabel = isConnected
|
||||
? translate(
|
||||
'auto.components.sidebar.HostRemoveDialog.alsoDeleteRemote',
|
||||
'Also delete these {{value0}} on {{value1}}',
|
||||
{ value0: workspaceCountLabel, value1: label }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.HostRemoveDialog.alsoForgetLocal',
|
||||
'Also remove these {{value0}} from Orca',
|
||||
{ value0: workspaceCountLabel }
|
||||
)
|
||||
const deleteOptionLabel = translate(
|
||||
'auto.components.sidebar.HostRemoveDialog.alsoForgetLocal',
|
||||
'Also remove these {{value0}} from Orca',
|
||||
{ value0: workspaceCountLabel }
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -267,15 +256,10 @@ export function HostRemoveDialog({
|
||||
<span className="min-w-0 flex-1 text-xs leading-snug">
|
||||
<span className="font-medium text-foreground">{deleteOptionLabel}</span>
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
{isConnected
|
||||
? translate(
|
||||
'auto.components.sidebar.HostRemoveDialog.alsoDeleteRemoteHint',
|
||||
'Permanently deletes the remote Git worktrees and their branches. Cannot be undone.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.HostRemoveDialog.alsoForgetLocalHint',
|
||||
'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.sidebar.HostRemoveDialog.alsoForgetLocalHint',
|
||||
'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,8 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
>(),
|
||||
repos: [] as { id: string; displayName: string; connectionId?: string }[],
|
||||
sshTargetLabels: new Map<string, string>(),
|
||||
sshConnectionStates: new Map<string, { status: string }>(),
|
||||
worktreeLineageById: {},
|
||||
allWorktrees: () => Array.from(state.worktreeMap.values()),
|
||||
clearWorktreeDeleteState: vi.fn((worktreeId: string) => {
|
||||
@@ -150,6 +152,8 @@ describe('delete worktree flow', () => {
|
||||
mocks.state.gitStatusByWorktree = {}
|
||||
mocks.state.worktreeLineageById = {}
|
||||
mocks.state.repos = []
|
||||
mocks.state.sshTargetLabels = new Map()
|
||||
mocks.state.sshConnectionStates = new Map()
|
||||
mocks.state.openFiles = []
|
||||
vi.mocked(toast.error).mockClear()
|
||||
vi.mocked(toast.info).mockClear()
|
||||
@@ -715,6 +719,23 @@ describe('delete worktree flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// #34677: a connected SSH target does not make this app's main able to delete there.
|
||||
it('offers only Remove from Orca for a workspace on a connected SSH target', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([{ id: 'repo-1::/srv/wt', displayName: 'wt', hostId: 'ssh:builder' }])
|
||||
mocks.state.repos = [{ id: 'repo-1', displayName: 'remote', connectionId: 'builder' }]
|
||||
mocks.state.sshTargetLabels = new Map([['builder', 'Builder']])
|
||||
mocks.state.sshConnectionStates = new Map([['builder', { status: 'connected' }]])
|
||||
|
||||
runWorktreeDelete('repo-1::/srv/wt')
|
||||
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith(
|
||||
'forget-ssh-workspace',
|
||||
expect.objectContaining({ resolution: { kind: 'saved', targetId: 'builder' } })
|
||||
)
|
||||
expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('can force confirmation for a single eligible delete', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([{ id: 'wt-1', displayName: 'one' }])
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './stale-workspace-list-toast'
|
||||
import { getWorkspaceDeleteLineage } from './workspace-delete-lineage'
|
||||
import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution'
|
||||
import { resolveWorktreeOperationRouteForHost } from '@/lib/worktree-operation-route'
|
||||
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getWorktreeExecutionHostId } from '../../../../shared/execution-host'
|
||||
@@ -60,7 +61,7 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
|
||||
state.clearWorktreeDeleteState(worktreeId)
|
||||
}
|
||||
|
||||
// Disconnected SSH needs reconnect/forget; paired clients lack desktop SSH metadata, so their RPC must handle deletion instead.
|
||||
// This app's own SSH rows can only be forgotten; paired clients lack desktop SSH metadata, so their RPC must handle deletion instead.
|
||||
const matchingRepos = state.repos.filter((entry) => entry.id === target.repoId)
|
||||
const repo = target.hostId
|
||||
? findRepoForHost(matchingRepos, target.repoId, { hostId: target.hostId })
|
||||
@@ -70,15 +71,18 @@ export function runWorktreeDelete(worktreeId: string, options: WorktreeDeleteOpt
|
||||
const sshResolution = isPairedWebClientWindow()
|
||||
? { kind: 'not-ssh' as const }
|
||||
: resolveSshWorkspaceForget({
|
||||
repo,
|
||||
sshConnectionStates: state.sshConnectionStates,
|
||||
route: resolveWorktreeOperationRouteForHost(
|
||||
state,
|
||||
worktreeId,
|
||||
getWorktreeExecutionHostId(target, repo ?? undefined)
|
||||
),
|
||||
sshTargetLabels: state.sshTargetLabels
|
||||
})
|
||||
if (sshResolution.kind === 'ghost' || sshResolution.kind === 'disconnected') {
|
||||
if (sshResolution.kind !== 'not-ssh') {
|
||||
// Metadata-only forgetting cannot fail on registered lineage children, so skip their removal warning.
|
||||
state.openModal('forget-ssh-workspace', {
|
||||
worktreeId,
|
||||
// Why (STA-11912): the reconnect awaits a connection, so the removal must stay pinned to this incarnation.
|
||||
// Why (STA-11912): a same-id replacement must not inherit this confirmation.
|
||||
worktreeDeleteIdentities: toWorktreeDeleteIdentities([target]),
|
||||
displayName: target.displayName,
|
||||
resolution: sshResolution
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveSshHostRemoval } from './ssh-host-remove-resolution'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
function connected(id: string): Map<string, SshConnectionState> {
|
||||
return new Map([[id, { targetId: id, status: 'connected', error: null, reconnectAttempt: 0 }]])
|
||||
}
|
||||
|
||||
describe('resolveSshHostRemoval', () => {
|
||||
const repos = [
|
||||
@@ -23,33 +18,20 @@ describe('resolveSshHostRemoval', () => {
|
||||
const result = resolveSshHostRemoval({
|
||||
targetId: 'ssh-1',
|
||||
repos,
|
||||
worktrees,
|
||||
sshConnectionStates: new Map()
|
||||
worktrees
|
||||
})
|
||||
expect(result.workspaceWorktreeIds.sort()).toEqual(
|
||||
['repo-a::/wt/feature', 'repo-b::/wt/x'].sort()
|
||||
)
|
||||
expect(result.hostRepoIds.sort()).toEqual(['repo-a', 'repo-b'])
|
||||
expect(result.workspaceCount).toBe(4)
|
||||
expect(result.isConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('reports connected when the target relay is connected', () => {
|
||||
const result = resolveSshHostRemoval({
|
||||
targetId: 'ssh-1',
|
||||
repos,
|
||||
worktrees,
|
||||
sshConnectionStates: connected('ssh-1')
|
||||
})
|
||||
expect(result.isConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('reports zero workspaces for a target nothing points at', () => {
|
||||
const result = resolveSshHostRemoval({
|
||||
targetId: 'ssh-unused',
|
||||
repos,
|
||||
worktrees,
|
||||
sshConnectionStates: new Map()
|
||||
worktrees
|
||||
})
|
||||
expect(result.workspaceCount).toBe(0)
|
||||
expect(result.hostRepoIds).toEqual([])
|
||||
@@ -67,8 +49,7 @@ describe('resolveSshHostRemoval', () => {
|
||||
{ id: 'repo-a::/wt/x', repoId: 'repo-a', isMainWorktree: false },
|
||||
{ id: 'repo-a::/wt/x', repoId: 'repo-a', isMainWorktree: false },
|
||||
{ id: 'repo-a::/wt/x', repoId: 'repo-a', isMainWorktree: false }
|
||||
],
|
||||
sshConnectionStates: new Map()
|
||||
]
|
||||
})
|
||||
expect(result.hostRepoIds).toEqual(['repo-a'])
|
||||
expect(result.workspaceWorktreeIds).toEqual(['repo-a::/wt/x'])
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
/** Connected hosts can delete remotely; offline hosts can forget local records, and empty hosts need no workspace choice. */
|
||||
/** A host's workspaces can only be forgotten here (this app's main serves no ssh: host); empty hosts need no workspace choice. */
|
||||
export type SshHostRemoveResolution = {
|
||||
targetId: string
|
||||
/** Ids of non-main worktrees on this host that a remote delete would remove. */
|
||||
/** Ids of non-main worktrees on this host. */
|
||||
workspaceWorktreeIds: string[]
|
||||
/** Repo ids whose main/root workspace lives on this host. */
|
||||
hostRepoIds: string[]
|
||||
workspaceCount: number
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function resolveSshHostRemoval(args: {
|
||||
targetId: string
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId'>[]
|
||||
worktrees: readonly Pick<Worktree, 'id' | 'repoId' | 'isMainWorktree'>[]
|
||||
sshConnectionStates: ReadonlyMap<string, SshConnectionState>
|
||||
}): SshHostRemoveResolution {
|
||||
const hostRepoIds = [
|
||||
...new Set(
|
||||
@@ -35,13 +32,11 @@ export function resolveSshHostRemoval(args: {
|
||||
.map((worktree) => worktree.id)
|
||||
)
|
||||
]
|
||||
const isConnected = args.sshConnectionStates.get(args.targetId)?.status === 'connected'
|
||||
return {
|
||||
targetId: args.targetId,
|
||||
workspaceWorktreeIds,
|
||||
hostRepoIds,
|
||||
// A repo's main/root workspace counts too — removing the host drops it.
|
||||
workspaceCount: workspaceWorktreeIds.length + hostRepoIds.length,
|
||||
isConnected
|
||||
workspaceCount: workspaceWorktreeIds.length + hostRepoIds.length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,24 +9,20 @@ export type ClearSshHostWorkspacesResult = {
|
||||
failedIds: string[]
|
||||
}
|
||||
|
||||
/** Remove children before root repos; delete remotely or forget records locally, collecting failures for the caller. */
|
||||
/** Forget children before root repos, collecting failures for the caller. */
|
||||
async function clearSshHostWorkspacesImpl(
|
||||
resolution: SshHostRemoveResolution,
|
||||
mode: 'delete-remote' | 'forget-local',
|
||||
confirmedDiscards: ConfirmedUnsavedDiscards | undefined
|
||||
): Promise<ClearSshHostWorkspacesResult> {
|
||||
const store = useAppStore.getState()
|
||||
const forgetLocalOnly = mode === 'forget-local'
|
||||
const failedIds: string[] = []
|
||||
// Pin removals to this SSH target so a same-path local checkout cannot be deleted.
|
||||
const hostId = toSshExecutionHostId(resolution.targetId)
|
||||
for (const worktreeId of resolution.workspaceWorktreeIds) {
|
||||
// Sequential deletes avoid Git ref-lock contention on the same repo.
|
||||
const result = await store.removeWorktree(
|
||||
{ id: worktreeId, executionHostId: hostId },
|
||||
false,
|
||||
forgetLocalOnly ? { mode: 'forget-local', confirmedDiscards } : { confirmedDiscards }
|
||||
)
|
||||
const result = await store.removeWorktree({ id: worktreeId, executionHostId: hostId }, false, {
|
||||
mode: 'forget-local',
|
||||
confirmedDiscards
|
||||
})
|
||||
if (!result.ok) {
|
||||
failedIds.push(worktreeId)
|
||||
}
|
||||
@@ -53,10 +49,9 @@ async function clearSshHostWorkspacesImpl(
|
||||
|
||||
export async function clearSshHostWorkspaces(
|
||||
resolution: SshHostRemoveResolution,
|
||||
mode: 'delete-remote' | 'forget-local',
|
||||
confirmedDiscards?: ConfirmedUnsavedDiscards
|
||||
): Promise<ClearSshHostWorkspacesResult> {
|
||||
return runEnclosingWorktreeRemoval({ hostId: toSshExecutionHostId(resolution.targetId) }, () =>
|
||||
clearSshHostWorkspacesImpl(resolution, mode, confirmedDiscards)
|
||||
clearSshHostWorkspacesImpl(resolution, confirmedDiscards)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveSshWorkspaceForget } from './ssh-workspace-forget-resolution'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
function stateMap(
|
||||
entries: Record<string, SshConnectionState['status']>
|
||||
): Map<string, SshConnectionState> {
|
||||
return new Map(
|
||||
Object.entries(entries).map(([id, status]) => [
|
||||
id,
|
||||
{ targetId: id, status, error: null, reconnectAttempt: 0 }
|
||||
])
|
||||
)
|
||||
const labels = new Map([['ssh-live', 'Live']])
|
||||
|
||||
function route(executionHostId: ExecutionHostId, runtimeEnvironmentId: string | null = null) {
|
||||
return { executionHostId, runtimeEnvironmentId }
|
||||
}
|
||||
|
||||
describe('resolveSshWorkspaceForget', () => {
|
||||
it('returns not-ssh for a repo with no connectionId', () => {
|
||||
const result = resolveSshWorkspaceForget({
|
||||
repo: { connectionId: null },
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map()
|
||||
})
|
||||
expect(result.kind).toBe('not-ssh')
|
||||
it('returns not-ssh for a local workspace', () => {
|
||||
expect(resolveSshWorkspaceForget({ route: route('local'), sshTargetLabels: labels }).kind).toBe(
|
||||
'not-ssh'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns not-ssh for a runtime-owned target', () => {
|
||||
const result = resolveSshWorkspaceForget({
|
||||
repo: { connectionId: 'runtime-ssh-abc' },
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map()
|
||||
})
|
||||
expect(result.kind).toBe('not-ssh')
|
||||
expect(
|
||||
resolveSshWorkspaceForget({ route: route('ssh:runtime-ssh-abc'), sshTargetLabels: labels })
|
||||
.kind
|
||||
).toBe('not-ssh')
|
||||
})
|
||||
|
||||
it('deletes through the Orca server that serves an SSH workspace', () => {
|
||||
expect(
|
||||
resolveSshWorkspaceForget({ route: route('ssh:ssh-live', 'env-1'), sshTargetLabels: labels })
|
||||
.kind
|
||||
).toBe('not-ssh')
|
||||
})
|
||||
|
||||
it('returns saved for a configured target this app would route to its own main', () => {
|
||||
expect(
|
||||
resolveSshWorkspaceForget({ route: route('ssh:ssh-live'), sshTargetLabels: labels })
|
||||
).toEqual({ kind: 'saved', targetId: 'ssh-live' })
|
||||
})
|
||||
|
||||
it('returns ghost when the target is no longer configured', () => {
|
||||
const result = resolveSshWorkspaceForget({
|
||||
repo: { connectionId: 'ssh-dead' },
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map()
|
||||
})
|
||||
expect(result).toEqual({ kind: 'ghost', targetId: 'ssh-dead' })
|
||||
})
|
||||
|
||||
it('returns connected when the configured target is connected', () => {
|
||||
const result = resolveSshWorkspaceForget({
|
||||
repo: { connectionId: 'ssh-live' },
|
||||
sshConnectionStates: stateMap({ 'ssh-live': 'connected' }),
|
||||
sshTargetLabels: new Map([['ssh-live', 'Live']])
|
||||
})
|
||||
expect(result).toEqual({ kind: 'connected', targetId: 'ssh-live' })
|
||||
})
|
||||
|
||||
it('returns disconnected when configured but not connected', () => {
|
||||
const result = resolveSshWorkspaceForget({
|
||||
repo: { connectionId: 'ssh-live' },
|
||||
sshConnectionStates: stateMap({ 'ssh-live': 'error' }),
|
||||
sshTargetLabels: new Map([['ssh-live', 'Live']])
|
||||
})
|
||||
expect(result).toEqual({ kind: 'disconnected', targetId: 'ssh-live', status: 'error' })
|
||||
})
|
||||
|
||||
it('defaults to disconnected status when configured target has no live state', () => {
|
||||
const result = resolveSshWorkspaceForget({
|
||||
repo: { connectionId: 'ssh-live' },
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map([['ssh-live', 'Live']])
|
||||
})
|
||||
expect(result).toEqual({ kind: 'disconnected', targetId: 'ssh-live', status: 'disconnected' })
|
||||
expect(
|
||||
resolveSshWorkspaceForget({ route: route('ssh:ssh-dead'), sshTargetLabels: labels })
|
||||
).toEqual({ kind: 'ghost', targetId: 'ssh-dead' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { WorktreeOperationRoute } from '@/lib/worktree-operation-route'
|
||||
|
||||
/** Choose normal deletion, reconnect-or-forget, or forget-only from SSH target state. */
|
||||
/** Choose normal deletion or forget-only from the route a removal would take. */
|
||||
export type SshWorkspaceForgetResolution =
|
||||
| { kind: 'not-ssh' }
|
||||
// Connected targets allow normal remote removal.
|
||||
| { kind: 'connected'; targetId: string }
|
||||
// Configured offline targets offer reconnect or local forget.
|
||||
| { kind: 'disconnected'; targetId: string; status: SshConnectionState['status'] }
|
||||
// Removed targets cannot reconnect; only local forget remains.
|
||||
// A saved target whose workspace this client can only forget.
|
||||
| { kind: 'saved'; targetId: string }
|
||||
// Removed targets keep only their local records.
|
||||
| { kind: 'ghost'; targetId: string }
|
||||
|
||||
export function resolveSshWorkspaceForget(args: {
|
||||
repo: Pick<Repo, 'connectionId'> | null | undefined
|
||||
sshConnectionStates: ReadonlyMap<string, SshConnectionState>
|
||||
route: WorktreeOperationRoute | null
|
||||
// Keys are configured SSH target ids (targets that still exist in settings).
|
||||
sshTargetLabels: ReadonlyMap<string, string>
|
||||
}): SshWorkspaceForgetResolution {
|
||||
const connectionId = args.repo?.connectionId?.trim()
|
||||
// Runtime-owned ephemeral targets manage their lifecycle and must never become user-facing ghosts.
|
||||
if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) {
|
||||
const host = parseExecutionHostId(args.route?.executionHostId)
|
||||
// An Orca server deletes the SSH workspaces it serves; this app's main serves no ssh: host, connected or not.
|
||||
if (
|
||||
host?.kind !== 'ssh' ||
|
||||
args.route?.runtimeEnvironmentId ||
|
||||
// Runtime-owned ephemeral targets manage their lifecycle and must never become user-facing ghosts.
|
||||
isRuntimeOwnedSshTargetId(host.targetId)
|
||||
) {
|
||||
return { kind: 'not-ssh' }
|
||||
}
|
||||
|
||||
const isConfigured = args.sshTargetLabels.has(connectionId)
|
||||
const status = args.sshConnectionStates.get(connectionId)?.status
|
||||
|
||||
// An unconfigured target cannot reconnect; offer local forget for its remaining records.
|
||||
if (!isConfigured) {
|
||||
return { kind: 'ghost', targetId: connectionId }
|
||||
}
|
||||
|
||||
if (status === 'connected') {
|
||||
return { kind: 'connected', targetId: connectionId }
|
||||
}
|
||||
|
||||
return { kind: 'disconnected', targetId: connectionId, status: status ?? 'disconnected' }
|
||||
return args.sshTargetLabels.has(host.targetId)
|
||||
? { kind: 'saved', targetId: host.targetId }
|
||||
: { kind: 'ghost', targetId: host.targetId }
|
||||
}
|
||||
|
||||
@@ -51,10 +51,6 @@ import { toast } from 'sonner'
|
||||
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
|
||||
import { runWorktreeDelete, runWorktreeDeletesInParallel } from './delete-worktree-flow'
|
||||
|
||||
function failureToastCount(): number {
|
||||
return vi.mocked(showDeleteWorktreeFailureToast).mock.calls.length
|
||||
}
|
||||
|
||||
function rowOnHost(hostId: ExecutionHostId): Worktree {
|
||||
return makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
@@ -105,7 +101,7 @@ beforeEach(() => {
|
||||
afterEach(cleanupHostCheckouts)
|
||||
|
||||
describe('STA-4343 sidebar delete: the confirmed row decides the host', () => {
|
||||
it('deletes the SSH row and leaves the ACTIVE local checkout on disk', async () => {
|
||||
it('offers to forget the confirmed SSH row and leaves the ACTIVE local checkout on disk', () => {
|
||||
const local = createHostCheckout(LOCAL_HOST)
|
||||
const ssh = createHostCheckout(SSH_HOST)
|
||||
const routedHostIds: string[] = []
|
||||
@@ -118,18 +114,15 @@ describe('STA-4343 sidebar delete: the confirmed row decides the host', () => {
|
||||
seedCollidingSidebar(store, SSH_HOST)
|
||||
|
||||
runWorktreeDelete(WORKTREE_ID, { expectedHostId: SSH_HOST })
|
||||
// Settle either outcome so the disk assertion reports misrouting rather than a missing spy call.
|
||||
await vi.waitFor(() => expect(routedHostIds.length + failureToastCount()).toBeGreaterThan(0))
|
||||
|
||||
expect(
|
||||
fs.existsSync(local.markerPath),
|
||||
`the ACTIVE local checkout must survive deleting the SSH row; removal was routed to hostId=${routedHostIds.join(',') || '<none>'}`
|
||||
).toBe(true)
|
||||
expect(fs.existsSync(ssh.markerPath), 'the confirmed SSH checkout must be gone').toBe(false)
|
||||
expect(routedHostIds).toEqual([SSH_HOST])
|
||||
expect(showDeleteWorktreeFailureToast).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo.repo1).toHaveLength(1)
|
||||
expect(store.getState().worktreesByRepo.repo1?.[0]?.hostId).toBe(LOCAL_HOST)
|
||||
// This app serves no ssh: host (#34677); only the SSH row's own records may be forgotten.
|
||||
expect(store.getState().openModal).toHaveBeenCalledWith(
|
||||
'forget-ssh-workspace',
|
||||
expect.objectContaining({ resolution: { kind: 'saved', targetId: 'ssh-1' } })
|
||||
)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(fs.existsSync(local.markerPath)).toBe(true)
|
||||
expect(store.getState().worktreesByRepo.repo1).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('routes the batch delete path to the confirmed host too', async () => {
|
||||
@@ -229,7 +222,8 @@ describe('STA-4343 sidebar delete: the confirmed row decides the host', () => {
|
||||
expect(store.getState().worktreesByRepo.repo1).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('still deletes an ordinary single-host SSH workspace for real', async () => {
|
||||
// #34677: nothing would serve the delete, so offer only Remove from Orca.
|
||||
it('never sends a destructive delete for an SSH workspace no server serves', async () => {
|
||||
const ssh = createHostCheckout(SSH_HOST)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
@@ -237,31 +231,32 @@ describe('STA-4343 sidebar delete: the confirmed row decides the host', () => {
|
||||
{ [SSH_HOST]: ssh.root },
|
||||
routedHostIds
|
||||
)
|
||||
const store = storeRef.current as ReturnType<typeof createTestStore>
|
||||
const store = createTestStore()
|
||||
storeRef.current = store
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/repo1',
|
||||
displayName: 'Repo 1',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
executionHostId: SSH_HOST,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { repo1: [rowOnHost(SSH_HOST)] },
|
||||
activeWorktreeId: null,
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: null,
|
||||
settings: { skipDeleteWorktreeConfirm: true },
|
||||
sshConnectionStates: new Map([['ssh-1', { targetId: 'ssh-1', status: 'connected' }]]),
|
||||
sshTargetLabels: new Map([['ssh-1', 'SSH One']]),
|
||||
worktreeLineageById: {}
|
||||
} as unknown as Partial<AppState>)
|
||||
})
|
||||
|
||||
runWorktreeDelete(WORKTREE_ID)
|
||||
await vi.waitFor(() => expect(routedHostIds).toEqual([SSH_HOST]))
|
||||
|
||||
expect(fs.existsSync(ssh.markerPath)).toBe(false)
|
||||
expect(showDeleteWorktreeFailureToast).not.toHaveBeenCalled()
|
||||
expect(store.getState().openModal).toHaveBeenCalledWith(
|
||||
'forget-ssh-workspace',
|
||||
expect.objectContaining({ resolution: { kind: 'saved', targetId: 'ssh-1' } })
|
||||
)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(fs.existsSync(ssh.markerPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
-2
@@ -768,8 +768,6 @@
|
||||
"stoppedBeforeStart": "Stopped manually"
|
||||
},
|
||||
"queuedMessages": {
|
||||
"imageCount_one": "{{count}} image",
|
||||
"imageCount_other": "{{count}} images",
|
||||
"editChanged": "This message changed while you were editing. Your edit is in the chat box.",
|
||||
"editChangedNotSaved": "This message changed while you were editing. Your edit wasn't saved.",
|
||||
"editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.",
|
||||
|
||||
@@ -6213,10 +6213,8 @@
|
||||
"oneWorkspace": "1 Arbeitsbereich",
|
||||
"manyWorkspaces": "{{count}} Arbeitsbereiche",
|
||||
"hostHasWorkspacesDefault": "Entfernt {{value0}} und die zugehörigen Zugangsdaten von diesem Computer. Orca behält {{value1}} bei — entfernte Dateien bleiben unberührt.",
|
||||
"alsoDeleteRemote": "Auch {{value0}} auf {{value1}} löschen",
|
||||
"alsoForgetLocal": "Auch {{value0}} aus Orca entfernen",
|
||||
"advanced": "Erweitert",
|
||||
"alsoDeleteRemoteHint": "Löscht die entfernten Git-Worktrees und ihre Branches dauerhaft. Kann nicht rückgängig gemacht werden.",
|
||||
"alsoForgetLocalHint": "Entfernt sie nur aus Orca. Entfernte Dateien, Worktrees und Branches bleiben unberührt.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Entfernt {{value0}} aus Orca. Sofern kein anderes auf diesem Computer geöffnetes Orca es verwendet, werden außerdem die offenen Terminals beendet und der Orca-Server in dieser Distribution gestoppt. Dateien in der Distribution bleiben unverändert."
|
||||
},
|
||||
@@ -6401,14 +6399,9 @@
|
||||
"sshImportSyncedSingular": "{{count}} Host zu Orca hinzugefügt."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Erneutes Verbinden fehlgeschlagen",
|
||||
"forgetBody": "Entfernt diesen Arbeitsbereich nur aus Orca. Dateien, der Git-Worktree und Branches auf {{host}} bleiben unberührt.",
|
||||
"title": "„{{name}}“ löschen?",
|
||||
"disconnectedBody": "Der SSH-Host dieses Arbeitsbereichs ist nicht verbunden. Erneut verbinden, um den Arbeitsbereich auch auf dem Remote-Host zu löschen, oder ihn nur aus Orca entfernen.",
|
||||
"ghostBody": "{{host}} ist kein gespeicherter SSH-Host mehr. Daher ist dieser Arbeitsbereich nicht mehr mit einem aktiven Host verbunden. Er kann nur aus Orca entfernt werden — Dateien und Branches auf dem Remote-Host bleiben unverändert.",
|
||||
"cancel": "Abbrechen",
|
||||
"forget": "Aus Orca entfernen",
|
||||
"reconnectAndDelete": "Erneut verbinden und löschen"
|
||||
"forget": "Aus Orca entfernen"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Bild",
|
||||
|
||||
@@ -6229,10 +6229,8 @@
|
||||
"oneWorkspace": "1 workspace",
|
||||
"manyWorkspaces": "{{count}} workspaces",
|
||||
"hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.",
|
||||
"alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}",
|
||||
"alsoForgetLocal": "Also remove these {{value0}} from Orca",
|
||||
"advanced": "Advanced",
|
||||
"alsoDeleteRemoteHint": "Permanently deletes the remote Git worktrees and their branches. Cannot be undone.",
|
||||
"alsoForgetLocalHint": "Clears them from Orca only. Remote files, worktrees, and branches are left untouched.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Removes {{value0}} from Orca. Unless another Orca that’s open on this computer is using it, this also ends its open terminals and stops Orca’s server in that distro. Files in the distro are untouched."
|
||||
},
|
||||
@@ -6417,14 +6415,11 @@
|
||||
"sshImportSyncedSingular": "Added {{count}} host to Orca."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Reconnection failed",
|
||||
"forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.",
|
||||
"title": "Delete “{{name}}”?",
|
||||
"disconnectedBody": "The SSH host for this workspace is not connected. Reconnect to delete it on the remote too, or remove it from Orca only.",
|
||||
"ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.",
|
||||
"cancel": "Cancel",
|
||||
"forget": "Remove from Orca",
|
||||
"reconnectAndDelete": "Reconnect & Delete"
|
||||
"removeTitle": "Remove “{{name}}” from Orca?",
|
||||
"savedHostBody": "This workspace’s project isn’t served by Orca’s server on {{host}}, so Orca can’t delete it there. You can remove it from Orca; files, the Git worktree, and branches on {{host}} are left untouched."
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Image",
|
||||
|
||||
@@ -5930,10 +5930,8 @@
|
||||
"oneWorkspace": "1 espacio de trabajo",
|
||||
"manyWorkspaces": "{{count}} espacios de trabajo",
|
||||
"hostHasWorkspacesDefault": "Elimina {{value0}} y sus credenciales de esta computadora. Sus {{value1}} permanecen en Orca — los archivos remotos no se tocan.",
|
||||
"alsoDeleteRemote": "También eliminar estos {{value0}} en {{value1}}",
|
||||
"alsoForgetLocal": "También eliminar estos {{value0}} de Orca",
|
||||
"advanced": "Avanzado",
|
||||
"alsoDeleteRemoteHint": "Elimina permanentemente los espacios de trabajo Git remotos y sus ramas. Esta acción no se puede deshacer.",
|
||||
"alsoForgetLocalHint": "Solo limpia de Orca. Los archivos, espacios de trabajo y ramas remotas permanecen intactos.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Quita {{value0}} de Orca. Salvo que otro Orca abierto en este equipo lo esté usando, también cierra sus terminales abiertas y detiene el servidor de Orca en esa distro. Los archivos de la distro no se tocan."
|
||||
},
|
||||
@@ -6089,14 +6087,9 @@
|
||||
"sshImportSyncedSingular": "Se agregó {{count}} host a Orca."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Error de reconexión",
|
||||
"forgetBody": "Elimina este espacio de trabajo solo de Orca. Los archivos, el worktree de Git y las ramas en {{host}} permanecen intactos.",
|
||||
"title": "¿Eliminar “{{name}}”?",
|
||||
"disconnectedBody": "El host SSH de este espacio de trabajo no está conectado. Reconéctalo para eliminarlo también en el remoto, o elimínalo solo de Orca.",
|
||||
"ghostBody": "{{host}} ya no es un host SSH guardado, por lo que este espacio de trabajo ya no está conectado a un host activo. Solo se puede eliminar de Orca — los archivos y ramas en el remoto permanecen intactos.",
|
||||
"cancel": "Cancelar",
|
||||
"forget": "Eliminar de Orca",
|
||||
"reconnectAndDelete": "Reconectar y eliminar"
|
||||
"forget": "Eliminar de Orca"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Image",
|
||||
|
||||
@@ -6052,10 +6052,8 @@
|
||||
"oneWorkspace": "1 espace de travail",
|
||||
"manyWorkspaces": "{{count}} espaces de travail",
|
||||
"hostHasWorkspacesDefault": "Retire {{value0}} et ses identifiants de cet ordinateur. Ses {{value1}} restent dans Orca — les fichiers distants ne sont pas touchés.",
|
||||
"alsoDeleteRemote": "Supprimer aussi ces {{value0}} sur {{value1}}",
|
||||
"alsoForgetLocal": "Retirer aussi ces {{value0}} d'Orca",
|
||||
"advanced": "Avancé",
|
||||
"alsoDeleteRemoteHint": "Supprime définitivement les worktrees Git distants et leurs branches. Irréversible.",
|
||||
"alsoForgetLocalHint": "Les retire uniquement d'Orca. Les fichiers, worktrees et branches distants restent intacts.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Retire {{value0}} d'Orca. Sauf si un autre Orca ouvert sur cet ordinateur l'utilise, cela ferme aussi ses terminaux ouverts et arrête le serveur d'Orca dans cette distro. Les fichiers de la distro restent intacts."
|
||||
},
|
||||
@@ -6240,14 +6238,9 @@
|
||||
"sshImportSyncedSingular": "Ajout de {{count}} hôte à Orca."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Échec de la reconnexion",
|
||||
"forgetBody": "Retire uniquement cet espace de travail d'Orca. Les fichiers, le worktree Git et les branches sur {{host}} restent intacts.",
|
||||
"title": "Supprimer « {{name}} » ?",
|
||||
"disconnectedBody": "L'hôte SSH de cet espace de travail n'est pas connecté. Reconnectez-le pour le supprimer aussi à distance, ou retirez-le seulement d'Orca.",
|
||||
"ghostBody": "{{host}} n'est plus un hôte SSH enregistré ; cet espace de travail n'est donc plus connecté à un hôte actif. Il ne peut être que retiré d'Orca — les fichiers et branches distants restent intacts.",
|
||||
"cancel": "Annuler",
|
||||
"forget": "Retirer d'Orca",
|
||||
"reconnectAndDelete": "Reconnecter et supprimer"
|
||||
"forget": "Retirer d'Orca"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Image",
|
||||
|
||||
@@ -5928,10 +5928,8 @@
|
||||
"oneWorkspace": "1つのワークスペース",
|
||||
"manyWorkspaces": "{{count}} 個のワークスペース",
|
||||
"hostHasWorkspacesDefault": "このコンピュータから {{value0}} とその認証情報を削除します。{{value1}} は Orca に残ります — リモートファイルは変更されません。",
|
||||
"alsoDeleteRemote": "また、{{value1}} のこれらの {{value0}} も削除",
|
||||
"alsoForgetLocal": "また、Orca からこれらの {{value0}} も削除",
|
||||
"advanced": "詳細設定",
|
||||
"alsoDeleteRemoteHint": "リモートの Git ワークツリーとそのブランチを完全に削除します。この操作は取り消せません。",
|
||||
"alsoForgetLocalHint": "Orca からのみ削除します。リモートのファイル、ワークツリー、ブランチはそのまま残ります。",
|
||||
"wslHostEndsTerminalsUnlessShared": "Orca から {{value0}} を削除します。このコンピューターで開いている別の Orca が使用していない場合は、開いているターミナルも終了し、そのディストリビューション内の Orca サーバーを停止します。ディストリビューション内のファイルは変更されません。"
|
||||
},
|
||||
@@ -6087,14 +6085,9 @@
|
||||
"sshImportSyncedSingular": "{{count}} 個のホストを Orca に追加しました。"
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "再接続に失敗しました",
|
||||
"forgetBody": "このワークスペースを Orca からのみ削除します。ファイル、Git ワークツリー、{{host}}のブランチはそのまま残ります。",
|
||||
"title": "「{{name}}」を削除しますか?",
|
||||
"disconnectedBody": "このワークスペースの SSH ホストが接続されていません。再接続してリモートでも削除するか、Orca からのみ削除してください。",
|
||||
"ghostBody": "{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。",
|
||||
"cancel": "キャンセル",
|
||||
"forget": "Orca から削除",
|
||||
"reconnectAndDelete": "再接続して削除"
|
||||
"forget": "Orca から削除"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "画像",
|
||||
|
||||
@@ -5928,10 +5928,8 @@
|
||||
"oneWorkspace": "워크스페이스 1개",
|
||||
"manyWorkspaces": "워크스페이스 {{count}}개",
|
||||
"hostHasWorkspacesDefault": "이 컴퓨터에서 {{value0}}과(와) 해당 자격 증명을 제거합니다. {{value1}}은(는) Orca에 남아 있습니다 — 원격 파일은 변경되지 않습니다.",
|
||||
"alsoDeleteRemote": "{{value1}}에서 이러한 {{value0}}도 삭제",
|
||||
"alsoForgetLocal": "Orca에서 이러한 {{value0}}도 제거",
|
||||
"advanced": "고급",
|
||||
"alsoDeleteRemoteHint": "원격 Git 워크트리와 해당 브랜치를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"alsoForgetLocalHint": "Orca에서만 제거합니다. 원격 파일, 워크트리, 브랜치는 그대로 남아 있습니다.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Orca에서 {{value0}}을(를) 제거합니다. 이 컴퓨터에서 열려 있는 다른 Orca가 사용 중이지 않다면 열린 터미널도 종료하고 해당 배포판의 Orca 서버를 중지합니다. 배포판의 파일은 변경되지 않습니다."
|
||||
},
|
||||
@@ -6087,14 +6085,9 @@
|
||||
"sshImportSyncedSingular": "호스트 {{count}}개를 Orca에 추가했습니다."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "재연결 실패",
|
||||
"forgetBody": "이 작업 공간을 Orca에서만 제거합니다. 파일, Git 워크트리, {{host}}의 브랜치는 그대로 유지됩니다.",
|
||||
"title": "Delete “{{name}}”?",
|
||||
"disconnectedBody": "이 워크스페이스의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요.",
|
||||
"ghostBody": "{{host}}은(는) 더 이상 저장된 SSH 호스트가 아니므로 이 워크스페이스는 라이브 호스트에 연결되어 있지 않습니다. Orca에서 제거만 할 수 있으며, 원격의 파일과 브랜치는 그대로 유지됩니다.",
|
||||
"cancel": "취소",
|
||||
"forget": "Orca에서 제거",
|
||||
"reconnectAndDelete": "재연결 후 삭제"
|
||||
"forget": "Orca에서 제거"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Image",
|
||||
|
||||
@@ -10620,10 +10620,8 @@
|
||||
"oneWorkspace": "1 espaço de trabalho",
|
||||
"manyWorkspaces": "{{count}} espaços de trabalho",
|
||||
"hostHasWorkspacesDefault": "Remove {{value0}} e suas credenciais deste computador. Seus(Suas) {{value1}} permanecem no Orca — os arquivos remotos não são tocados.",
|
||||
"alsoDeleteRemote": "Também excluir estes(as) {{value0}} em {{value1}}",
|
||||
"alsoForgetLocal": "Também remover estes(as) {{value0}} do Orca",
|
||||
"advanced": "Avançado",
|
||||
"alsoDeleteRemoteHint": "Exclui permanentemente as worktrees Git remotas e suas branches. Não pode ser desfeito.",
|
||||
"alsoForgetLocalHint": "Limpa-os apenas do Orca. Os arquivos, worktrees e branches remotos permanecem intocados.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Remove {{value0}} do Orca. A menos que outro Orca aberto neste computador o esteja usando, também encerra seus terminais abertos e para o servidor do Orca nessa distro. Os arquivos da distro não são alterados."
|
||||
},
|
||||
@@ -10936,14 +10934,9 @@
|
||||
"6c07f3a91e": "{{value0}} em {{value1}}"
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Falha na reconexão",
|
||||
"forgetBody": "Remove este espaço de trabalho apenas do Orca. Os arquivos, a worktree Git e as branches em {{host}} permanecem intocados.",
|
||||
"title": "Excluir “{{name}}”?",
|
||||
"disconnectedBody": "O host SSH deste espaço de trabalho não está conectado. Reconecte para excluí-lo também no remoto, ou remova-o somente do Orca.",
|
||||
"ghostBody": "{{host}} não é mais um host SSH salvo, então este espaço de trabalho não está mais conectado a um host ativo. Ele só pode ser removido do Orca — os arquivos e branches no remoto permanecem intocados.",
|
||||
"cancel": "Cancelar",
|
||||
"forget": "Remover do Orca",
|
||||
"reconnectAndDelete": "Reconectar e Excluir"
|
||||
"forget": "Remover do Orca"
|
||||
},
|
||||
"RepoScanUnavailableIndicator": {
|
||||
"title": "Falha na varredura de worktree para {{value0}}",
|
||||
|
||||
@@ -6206,10 +6206,8 @@
|
||||
"oneWorkspace": "1 рабочее пространство",
|
||||
"manyWorkspaces": "рабочие пространства ({{count}})",
|
||||
"hostHasWorkspacesDefault": "Удаляет {{value0}} и его учётные данные с этого компьютера. В Orca без изменений: {{value1}}. Файлы на сервере сохранятся.",
|
||||
"alsoDeleteRemote": "Также удалить {{value0}} на {{value1}}",
|
||||
"alsoForgetLocal": "Также убрать {{value0}} из Orca",
|
||||
"advanced": "Дополнительно",
|
||||
"alsoDeleteRemoteHint": "Навсегда удаляет удалённые рабочие деревья Git и их ветки. Это действие нельзя отменить.",
|
||||
"alsoForgetLocalHint": "Только убирает их из Orca. Удалённые файлы, рабочие деревья и ветки сохранятся.",
|
||||
"wslHostEndsTerminalsUnlessShared": "Удаляет {{value0}} из Orca. Если только его не использует другой Orca, открытый на этом компьютере, также закрывает его открытые терминалы и останавливает сервер Orca в этом дистрибутиве. Файлы в дистрибутиве не затрагиваются."
|
||||
},
|
||||
@@ -6394,14 +6392,9 @@
|
||||
"sshImportSyncedSingular": "В Orca добавлен {{count}} сервер."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Не удалось восстановить подключение",
|
||||
"forgetBody": "Только убирает это рабочее пространство из Orca. Файлы, рабочее дерево Git и ветки на {{host}} сохранятся.",
|
||||
"title": "Удалить «{{name}}»?",
|
||||
"disconnectedBody": "SSH-сервер этого рабочего пространства не подключён. Подключитесь заново, чтобы удалить его и на сервере, или только уберите из Orca.",
|
||||
"ghostBody": "{{host}} больше не сохранён как SSH-сервер, поэтому рабочее пространство не связано с действующим компьютером. Его можно только убрать из Orca — файлы и ветки на сервере сохранятся.",
|
||||
"cancel": "Отмена",
|
||||
"forget": "Убрать из Orca",
|
||||
"reconnectAndDelete": "Подключиться и удалить"
|
||||
"forget": "Убрать из Orca"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Изображение",
|
||||
|
||||
@@ -6178,10 +6178,8 @@
|
||||
"oneWorkspace": "1 çalışma alanı",
|
||||
"manyWorkspaces": "{{count}} çalışma alanı",
|
||||
"hostHasWorkspacesDefault": "{{value0}} öğesini ve kimlik bilgilerini bu bilgisayardan kaldırır. {{value1}} Orca'da kalır, uzak dosyalara dokunulmaz.",
|
||||
"alsoDeleteRemote": "{{value1}} üzerindeki bu {{value0}} öğesini de sil",
|
||||
"alsoForgetLocal": "Bu {{value0}} öğesini Orca'dan da kaldır",
|
||||
"advanced": "Gelişmiş",
|
||||
"alsoDeleteRemoteHint": "Uzak Git worktree'lerini ve branch'lerini kalıcı olarak siler. Geri alınamaz.",
|
||||
"alsoForgetLocalHint": "Yalnızca Orca'dan temizler. Uzak dosyalar, worktree'ler ve branch'ler dokunulmadan bırakılır.",
|
||||
"wslHostEndsTerminalsUnlessShared": "{{value0}} öğesini Orca'dan kaldırır. Bu bilgisayarda açık olan başka bir Orca onu kullanmıyorsa açık terminallerini de sonlandırır ve bu dağıtımdaki Orca sunucusunu durdurur. Dağıtımdaki dosyalar değiştirilmez."
|
||||
},
|
||||
@@ -6366,14 +6364,9 @@
|
||||
"sshImportSyncedSingular": "Orca'ya {{count}} sunucu eklendi."
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "Yeniden bağlanma başarısız oldu",
|
||||
"forgetBody": "Bu çalışma alanını yalnızca Orca'dan kaldırır. {{host}} üzerindeki dosyalar, Git worktree'si ve branch'ler dokunulmadan bırakılır.",
|
||||
"title": "“{{name}}” silinsin mi?",
|
||||
"disconnectedBody": "Bu çalışma alanının SSH sunucusu bağlı değil. Uzak sunucuda da silmek için yeniden bağlanın veya yalnızca Orca'dan kaldırın.",
|
||||
"ghostBody": "{{host}} artık kayıtlı bir SSH sunucusu olmadığı için bu çalışma alanı etkin bir sunucuya bağlı değil. Yalnızca Orca'dan kaldırılabilir; uzak sunucudaki dosyalar ve branch'ler değiştirilmez.",
|
||||
"cancel": "İptal",
|
||||
"forget": "Orca'dan Kaldır",
|
||||
"reconnectAndDelete": "Yeniden Bağlan ve Sil"
|
||||
"forget": "Orca'dan Kaldır"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Görsel",
|
||||
|
||||
@@ -6167,10 +6167,8 @@
|
||||
"oneWorkspace": "1 個工作區",
|
||||
"manyWorkspaces": "{{count}} 個工作區",
|
||||
"hostHasWorkspacesDefault": "從此電腦移除 {{value0}} 及其憑證。其 {{value1}} 保留在 Orca 中 — 遠端檔案不會被刪除。",
|
||||
"alsoDeleteRemote": "同時在 {{value1}} 上刪除這些 {{value0}}",
|
||||
"alsoForgetLocal": "同時從 Orca 中移除這些 {{value0}}",
|
||||
"advanced": "進階",
|
||||
"alsoDeleteRemoteHint": "永久刪除遠端 Git 工作樹及其分支。此操作不可復原。",
|
||||
"alsoForgetLocalHint": "僅從 Orca 中清除。遠端檔案、工作樹和分支不受影響。",
|
||||
"wslHostEndsTerminalsUnlessShared": "從 Orca 移除 {{value0}}。除非此電腦上開啟的另一個 Orca 正在使用它,否則也會結束其開啟的終端機,並停止該發行版中的 Orca 伺服器。發行版中的檔案不受影響。"
|
||||
},
|
||||
@@ -6355,14 +6353,9 @@
|
||||
"sshImportSyncedSingular": "已將 {{count}} 台主機新增至 Orca。"
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "重新連線失敗",
|
||||
"forgetBody": "僅從 Orca 中移除此工作區。檔案、Git 工作樹和 {{host}} 上的分支保持不變。",
|
||||
"title": "刪除「{{name}}」?",
|
||||
"disconnectedBody": "此工作區的 SSH 主機未連線。重新連線以同時在遠端刪除,或僅從 Orca 中移除。",
|
||||
"ghostBody": "{{host}} 不再是已儲存的 SSH 主機,因此此工作區不再連線到活躍主機。只能從 Orca 中移除 — 遠端檔案和分支保持不變。",
|
||||
"cancel": "取消",
|
||||
"forget": "從 Orca 中移除",
|
||||
"reconnectAndDelete": "重新連線並刪除"
|
||||
"forget": "從 Orca 中移除"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "圖片",
|
||||
|
||||
@@ -5928,10 +5928,8 @@
|
||||
"oneWorkspace": "1 个工作区",
|
||||
"manyWorkspaces": "{{count}} 个工作区",
|
||||
"hostHasWorkspacesDefault": "从此计算机移除 {{value0}} 及其凭据。其 {{value1}} 保留在 Orca 中 — 远程文件不会被删除。",
|
||||
"alsoDeleteRemote": "同时在 {{value1}} 上删除这些 {{value0}}",
|
||||
"alsoForgetLocal": "同时从 Orca 中移除这些 {{value0}}",
|
||||
"advanced": "高级",
|
||||
"alsoDeleteRemoteHint": "永久删除远程 Git 工作树及其分支。此操作不可撤销。",
|
||||
"alsoForgetLocalHint": "仅从 Orca 中清除。远程文件、工作树和分支均不受影响。",
|
||||
"wslHostEndsTerminalsUnlessShared": "从 Orca 中移除 {{value0}}。除非此计算机上打开的另一个 Orca 正在使用它,否则还会结束其打开的终端,并停止该发行版中的 Orca 服务器。发行版中的文件不受影响。"
|
||||
},
|
||||
@@ -6087,14 +6085,9 @@
|
||||
"sshImportSyncedSingular": "已将 {{count}} 个主机添加到 Orca。"
|
||||
},
|
||||
"ForgetSshWorkspaceDialog": {
|
||||
"reconnectFailed": "重新连接失败",
|
||||
"forgetBody": "仅从 Orca 中移除此工作区。文件、Git 工作树和 {{host}} 上的分支保持不变。",
|
||||
"title": "从 Orca 中移除“{{name}}”?",
|
||||
"disconnectedBody": "此工作区的 SSH 主机未连接。重新连接以同时在远程删除,或仅从 Orca 中移除。",
|
||||
"ghostBody": "{{host}} 不再是已保存的 SSH 主机,因此此工作区不再连接到活跃主机。只能从 Orca 中移除 — 远程文件和分支保持不变。",
|
||||
"cancel": "取消",
|
||||
"forget": "从 Orca 中移除",
|
||||
"reconnectAndDelete": "重新连接并删除"
|
||||
"forget": "从 Orca 中移除"
|
||||
},
|
||||
"MarkdownImageLightbox": {
|
||||
"image": "Image",
|
||||
|
||||
@@ -80,8 +80,7 @@ const resolution = {
|
||||
targetId: 'host',
|
||||
workspaceWorktreeIds: [active.id],
|
||||
hostRepoIds: ['repo1'],
|
||||
workspaceCount: 1,
|
||||
isConnected: false
|
||||
workspaceCount: 1
|
||||
}
|
||||
|
||||
describe('deleting the active workspace', () => {
|
||||
@@ -115,9 +114,7 @@ describe('deleting the active workspace', () => {
|
||||
throw new Error('host unavailable')
|
||||
})
|
||||
removeForHost.mockRejectedValueOnce(new Error('host unavailable'))
|
||||
expect((await clearSshHostWorkspaces(resolution, 'forget-local')).failedIds).toContain(
|
||||
active.id
|
||||
)
|
||||
expect((await clearSshHostWorkspaces(resolution)).failedIds).toContain(active.id)
|
||||
expect(store.getState().activeWorktreeId).toBe(active.id)
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
await forget(store, active)
|
||||
@@ -129,7 +126,7 @@ describe('deleting the active workspace', () => {
|
||||
const tab = store.getState().createTab(active.id)
|
||||
store.getState().closeTab(tab.id, { reason: 'pty-exit' })
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
await clearSshHostWorkspaces(resolution, 'forget-local')
|
||||
await clearSshHostWorkspaces(resolution)
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user