mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): move the open workspace to the server's linked project when joining an existing server (#30189)
* fix(ssh): move the open workspace to the server's linked project when joining an existing server * fix(ssh): follow the active repo and detected checkouts when rehoming a linked workspace * fix(ssh): treat a missing server row as unknown, and keep watching for it
This commit is contained in:
@@ -222,6 +222,152 @@ describe('a host that just moved to its managed server', () => {
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
})
|
||||
|
||||
describe('a project linked to the copy another desktop already put on the server', () => {
|
||||
const sourceRepo: Repo = { ...repo, id: 'repo-b', path: '/root/migrepo' }
|
||||
const sourceWorktreeId = 'repo-b::/root/migrepo'
|
||||
const serverWorktreeId = 'repo-a::/root/migrepo'
|
||||
const linked = [{ path: '/root/migrepo', automations: 0 }]
|
||||
|
||||
beforeEach(() => {
|
||||
const call = vi.fn(async (args: RuntimeEnvironmentCallRequest) =>
|
||||
args.method === 'repo.list'
|
||||
? {
|
||||
id: 'rpc',
|
||||
ok: true,
|
||||
result: { repos: [{ ...repo, id: 'repo-a', path: '/root/migrepo' }] },
|
||||
_meta: { runtimeId: 'orcad' }
|
||||
}
|
||||
: serverReply(args)
|
||||
)
|
||||
Object.assign(window.api.runtimeEnvironments, { call })
|
||||
store.setState({
|
||||
repos: [{ ...sourceRepo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }],
|
||||
worktreesByRepo: {
|
||||
'repo-b': [
|
||||
makeWorktree({
|
||||
id: sourceWorktreeId,
|
||||
repoId: 'repo-b',
|
||||
path: '/root/migrepo',
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
]
|
||||
},
|
||||
activeRepoId: 'repo-b',
|
||||
activeWorktreeId: sourceWorktreeId,
|
||||
activeWorkspaceExecutionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
})
|
||||
|
||||
const serverRows = (environmentId: string) => ({
|
||||
'repo-a': [
|
||||
makeWorktree({
|
||||
id: serverWorktreeId,
|
||||
repoId: 'repo-a',
|
||||
path: '/root/migrepo',
|
||||
hostId: `runtime:${environmentId}`
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
it('moves the open workspace onto the server’s project, whose id differs', async () => {
|
||||
store.setState({
|
||||
worktreesByRepo: { ...store.getState().worktreesByRepo, ...serverRows('env-link') }
|
||||
})
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-link',
|
||||
linked
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link')
|
||||
)
|
||||
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
|
||||
expect(store.getState().activeRepoId).toBe('repo-a')
|
||||
})
|
||||
|
||||
it('finds a checkout the server only detects, as its visibility settings hide it', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-link-hidden',
|
||||
linked
|
||||
})
|
||||
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
|
||||
|
||||
store.setState({
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-a': {
|
||||
repoId: 'repo-a',
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: serverRows('env-link-hidden')['repo-a'].map((worktree) => ({
|
||||
...worktree,
|
||||
ownership: 'external' as const,
|
||||
selectedCheckout: false,
|
||||
visible: false
|
||||
}))
|
||||
}
|
||||
},
|
||||
startupWorktreeRefreshCompleted: true
|
||||
})
|
||||
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link-hidden')
|
||||
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
|
||||
})
|
||||
|
||||
it('moves it once the cold-start scan delivers the server’s rows', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-link-cold',
|
||||
linked
|
||||
})
|
||||
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
|
||||
expect(store.getState().activeWorktreeId).toBe(sourceWorktreeId)
|
||||
|
||||
store.setState({
|
||||
worktreesByRepo: serverRows('env-link-cold'),
|
||||
startupWorktreeRefreshCompleted: true
|
||||
})
|
||||
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link-cold')
|
||||
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
|
||||
})
|
||||
|
||||
it('keeps the selection while a failed scan leaves the server’s row unknown', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-link-failed-scan',
|
||||
linked
|
||||
})
|
||||
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
|
||||
// Startup's scan completed, but the server's listing failed and delivered no rows.
|
||||
store.setState({ startupWorktreeRefreshCompleted: true })
|
||||
expect(store.getState().activeWorktreeId).toBe(sourceWorktreeId)
|
||||
|
||||
store.setState({ worktreesByRepo: serverRows('env-link-failed-scan') })
|
||||
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-link-failed-scan')
|
||||
expect(store.getState().activeWorktreeId).toBe(serverWorktreeId)
|
||||
})
|
||||
|
||||
it('selects nothing when the server has no copy to move to', async () => {
|
||||
applySshManagedServerTransition('ssh-1', undefined, {
|
||||
kind: 'managed',
|
||||
environmentId: 'env-unlinked'
|
||||
})
|
||||
await vi.waitFor(() => expect(store.getState().worktreesByRepo['repo-b']).toEqual([]))
|
||||
|
||||
store.setState({
|
||||
worktreesByRepo: serverRows('env-unlinked'),
|
||||
startupWorktreeRefreshCompleted: true
|
||||
})
|
||||
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBeNull()
|
||||
expect(store.getState().activeRepoId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a workspace open on another host where it is', async () => {
|
||||
store.setState({
|
||||
activeRepoId: 'repo-1',
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/** What a change in an SSH host's server means for the rest of the app. */
|
||||
import { toast } from 'sonner'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type {
|
||||
SshConnectionState,
|
||||
SshManagedServerLinkedProject
|
||||
} from '../../../../shared/ssh-types'
|
||||
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
|
||||
import { splitWorktreeId } from '../../../../shared/worktree/id'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import {
|
||||
type ExecutionHostId,
|
||||
getRepoExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId
|
||||
@@ -23,7 +31,7 @@ export function applySshManagedServerTransition(
|
||||
// Starts and wakes keep the same owner; only a new server or a failed load needs a refresh.
|
||||
const current = catalogLoadByTarget.get(targetId)
|
||||
if (current?.environmentId !== next.environmentId) {
|
||||
startManagedServerCatalogLoad(targetId, next.environmentId)
|
||||
startManagedServerCatalogLoad(targetId, next.environmentId, next.linked ?? [])
|
||||
} else if (current.pending && previous?.kind !== 'managed') {
|
||||
// Why: a reconnect during a doomed read must not be lost; replay it once if that read fails.
|
||||
current.reconnectedWhilePending = true
|
||||
@@ -80,14 +88,19 @@ function remountBridgedTabsOnce(targetId: string, next: ManagedServerStatus): vo
|
||||
|
||||
type CatalogLoad = {
|
||||
environmentId: string
|
||||
linked: readonly SshManagedServerLinkedProject[]
|
||||
pending: boolean
|
||||
reconnectedWhilePending: boolean
|
||||
}
|
||||
|
||||
const catalogLoadByTarget = new Map<string, CatalogLoad>()
|
||||
|
||||
function startManagedServerCatalogLoad(targetId: string, environmentId: string): void {
|
||||
const load: CatalogLoad = { environmentId, pending: true, reconnectedWhilePending: false }
|
||||
function startManagedServerCatalogLoad(
|
||||
targetId: string,
|
||||
environmentId: string,
|
||||
linked: readonly SshManagedServerLinkedProject[]
|
||||
): void {
|
||||
const load: CatalogLoad = { environmentId, linked, pending: true, reconnectedWhilePending: false }
|
||||
catalogLoadByTarget.set(targetId, load)
|
||||
void loadManagedServerCatalogs(targetId, load).then(
|
||||
() => {
|
||||
@@ -102,7 +115,7 @@ function startManagedServerCatalogLoad(targetId: string, environmentId: string):
|
||||
}
|
||||
catalogLoadByTarget.delete(targetId)
|
||||
if (load.reconnectedWhilePending) {
|
||||
startManagedServerCatalogLoad(targetId, environmentId)
|
||||
startManagedServerCatalogLoad(targetId, environmentId, load.linked)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -155,6 +168,13 @@ async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): P
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
// Read before the source rows go: a linked project keeps the server's id, not this desktop's.
|
||||
const linkedRepoIds = linkedServerRepoIds(
|
||||
useAppStore.getState().repos,
|
||||
targetId,
|
||||
environmentId,
|
||||
load.linked
|
||||
)
|
||||
useAppStore.setState((state) => {
|
||||
// Why: the host's workspaces are the server's now; unmoved, the next prune drops their recency.
|
||||
const lastVisitedAtByWorktreeId = rekeyWorktreeVisitHost(
|
||||
@@ -169,39 +189,140 @@ async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): P
|
||||
: { lastVisitedAtByWorktreeId })
|
||||
}
|
||||
})
|
||||
rehomeActiveWorkspace(targetId, environmentId)
|
||||
if (!useAppStore.getState().startupWorktreeRefreshCompleted) {
|
||||
rehomeAfterStartupScan(targetId, environmentId, isCurrent)
|
||||
}
|
||||
rehomeActiveWorkspaceWhenKnown(targetId, environmentId, linkedRepoIds, isCurrent)
|
||||
}
|
||||
|
||||
/** Why: on cold start the server's worktree rows arrive with startup's scan, after the attempt above. */
|
||||
function rehomeAfterStartupScan(
|
||||
/**
|
||||
* Why keep watching: the server's worktree rows arrive with startup's scan on cold start, and a
|
||||
* failed scan leaves them missing until a later one, so a missing row never reads as no copy.
|
||||
*/
|
||||
function rehomeActiveWorkspaceWhenKnown(
|
||||
targetId: string,
|
||||
environmentId: string,
|
||||
linkedRepoIds: ReadonlyMap<string, string>,
|
||||
isCurrent: () => boolean
|
||||
): void {
|
||||
const unsubscribe = useAppStore.subscribe((state) => {
|
||||
if (rehomeActiveWorkspace(targetId, environmentId, linkedRepoIds) !== 'pending') {
|
||||
return
|
||||
}
|
||||
const unsubscribe = useAppStore.subscribe((state, previous) => {
|
||||
if (!isCurrent()) {
|
||||
unsubscribe()
|
||||
return
|
||||
}
|
||||
if (state.startupWorktreeRefreshCompleted) {
|
||||
if (
|
||||
state.worktreesByRepo === previous.worktreesByRepo &&
|
||||
state.detectedWorktreesByRepo === previous.detectedWorktreesByRepo &&
|
||||
state.activeWorktreeId === previous.activeWorktreeId &&
|
||||
state.activeWorkspaceExecutionHostId === previous.activeWorkspaceExecutionHostId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (rehomeActiveWorkspace(targetId, environmentId, linkedRepoIds) !== 'pending') {
|
||||
unsubscribe()
|
||||
rehomeActiveWorkspace(targetId, environmentId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** This desktop's project id → the server's, for each project the move linked at the same path. */
|
||||
function linkedServerRepoIds(
|
||||
repos: readonly Repo[],
|
||||
targetId: string,
|
||||
environmentId: string,
|
||||
linked: readonly SshManagedServerLinkedProject[]
|
||||
): Map<string, string> {
|
||||
const linkedPaths = new Set(linked.map((entry) => normalizeRuntimePathForComparison(entry.path)))
|
||||
const sshHostId = toSshExecutionHostId(targetId)
|
||||
const runtimeHostId = toRuntimeExecutionHostId(environmentId)
|
||||
const serverIdByPath = new Map(
|
||||
repos
|
||||
.filter((repo) => getRepoExecutionHostId(repo) === runtimeHostId)
|
||||
.map((repo) => [normalizeRuntimePathForComparison(repo.path), repo.id])
|
||||
)
|
||||
const mapping = new Map<string, string>()
|
||||
for (const repo of repos) {
|
||||
const path = normalizeRuntimePathForComparison(repo.path)
|
||||
const serverId = serverIdByPath.get(path)
|
||||
if (getRepoExecutionHostId(repo) === sshHostId && linkedPaths.has(path) && serverId) {
|
||||
mapping.set(repo.id, serverId)
|
||||
}
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
|
||||
type ServerCopy = { kind: 'found'; worktreeId: string } | { kind: 'absent' } | { kind: 'pending' }
|
||||
|
||||
/**
|
||||
* The server's copy of a workspace: the same id, or the same checkout in the project it linked to.
|
||||
* Only a project the server's catalog lacks is absent; that catalog loaded or this never runs.
|
||||
*/
|
||||
function serverCopyOfWorkspace(
|
||||
worktreeId: string,
|
||||
runtimeHostId: ExecutionHostId,
|
||||
linkedRepoIds: ReadonlyMap<string, string>
|
||||
): ServerCopy {
|
||||
const state = useAppStore.getState()
|
||||
if (state.getKnownWorktreeById(worktreeId, runtimeHostId)) {
|
||||
return { kind: 'found', worktreeId }
|
||||
}
|
||||
const source =
|
||||
parseWorkspaceKey(worktreeId)?.type === 'folder' ? null : splitWorktreeId(worktreeId)
|
||||
if (!source) {
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
const serverRepoId = state.repos.some(
|
||||
(repo) => repo.id === source.repoId && getRepoExecutionHostId(repo) === runtimeHostId
|
||||
)
|
||||
? source.repoId
|
||||
: linkedRepoIds.get(source.repoId)
|
||||
if (!serverRepoId) {
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
const path = normalizeRuntimePathForComparison(source.worktreePath)
|
||||
// Why detected too: a checkout the server's visibility settings hide still exists there.
|
||||
const row = [
|
||||
...(state.worktreesByRepo[serverRepoId] ?? []),
|
||||
...(state.detectedWorktreesByRepo[serverRepoId]?.worktrees ?? [])
|
||||
].find(
|
||||
(worktree) =>
|
||||
worktree.hostId === runtimeHostId && normalizeRuntimePathForComparison(worktree.path) === path
|
||||
)
|
||||
return row ? { kind: 'found', worktreeId: row.id } : { kind: 'pending' }
|
||||
}
|
||||
|
||||
/** Why: an active workspace left on the SSH host keeps showing panes that dial the stopped relay. */
|
||||
function rehomeActiveWorkspace(targetId: string, environmentId: string): void {
|
||||
function rehomeActiveWorkspace(
|
||||
targetId: string,
|
||||
environmentId: string,
|
||||
linkedRepoIds: ReadonlyMap<string, string>
|
||||
): 'settled' | 'pending' {
|
||||
const state = useAppStore.getState()
|
||||
const runtimeHostId = toRuntimeExecutionHostId(environmentId)
|
||||
if (
|
||||
state.activeWorktreeId &&
|
||||
state.activeWorkspaceExecutionHostId === toSshExecutionHostId(targetId) &&
|
||||
state.getKnownWorktreeById(state.activeWorktreeId, runtimeHostId)
|
||||
!state.activeWorktreeId ||
|
||||
state.activeWorkspaceExecutionHostId !== toSshExecutionHostId(targetId)
|
||||
) {
|
||||
state.setActiveWorktree(state.activeWorktreeId, runtimeHostId)
|
||||
return 'settled'
|
||||
}
|
||||
const copy = serverCopyOfWorkspace(state.activeWorktreeId, runtimeHostId, linkedRepoIds)
|
||||
if (copy.kind === 'pending') {
|
||||
return 'pending'
|
||||
}
|
||||
if (copy.kind === 'found') {
|
||||
// Why a transition: it makes the active repo follow the row, which a linked project renames.
|
||||
state.setActiveWorktree(copy.worktreeId, runtimeHostId, {
|
||||
stateTransition: () => ({ patch: {}, activate: true })
|
||||
})
|
||||
return 'settled'
|
||||
}
|
||||
// The server holds no project for it, so the SSH owner it names is retired.
|
||||
state.setActiveWorktree(null, undefined, {
|
||||
stateTransition: (current) => ({
|
||||
patch: current.repos.some((repo) => repo.id === current.activeRepoId)
|
||||
? {}
|
||||
: { activeRepoId: null },
|
||||
activate: true
|
||||
})
|
||||
})
|
||||
return 'settled'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user