fix(orcad): a converted host shows only its managed server's rows, and its old editor tabs load there (#25193)

Retained relay-era project groups and the per-host SSH catalog now hide like repos and folders.
On the managed transition the renderer reloads server names, groups, folders and worktrees, then
drops the host's relay-era rows a local refresh would keep. A restored tab with no host stamp in
a workspace now owned by a managed server takes that server as owner, in place.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
OrcaWin
2026-10-04 03:17:16 -07:00
committed by GitHub
co-authored by m4air Claude
parent cc027b7a87
commit b4f55be5f8
15 changed files with 635 additions and 36 deletions
+4 -2
View File
@@ -25,7 +25,8 @@ export type ReposIpcMocks = {
| 'updateProjectGroup'
| 'deleteProjectGroup'
| 'moveProjectToGroup'
| 'getSshTarget',
| 'getSshTarget'
| 'getSshTargets',
ReposIpcSpy
> & { updateRepo: Mock<(repoId: string, updates: Record<string, unknown>) => unknown> }
mockGitProvider: Record<
@@ -62,7 +63,8 @@ export function createReposIpcMocks(): ReposIpcMocks {
updateProjectGroup: vi.fn(),
deleteProjectGroup: vi.fn(),
moveProjectToGroup: vi.fn(),
getSshTarget: vi.fn()
getSshTarget: vi.fn(),
getSshTargets: vi.fn().mockReturnValue([])
},
mockGitProvider: {
isGitRepo: vi.fn().mockReturnValue(true),
@@ -12,6 +12,7 @@ import {
import { isAdmissibleDirectSshAuthority } from '../../../shared/ssh-retained-payload-admission'
import { isCurrentSshProviderAuthority } from '../../ssh/ssh-provider-authority'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { visibleRepos } from '../../ssh/orcad-retained-source'
function hasValidCatalogSshAuthority(
args: ListReposForExecutionHostArgs
@@ -62,7 +63,7 @@ export async function listReposForExecutionHost(
if ('expectedAuthority' in args) {
return rejected('rejected')
}
const repos = getConsistentRepoCatalogForHost(store.getRepos(), parsedHost)
const repos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost)
if (!repos) {
return rejected('rejected')
}
@@ -86,7 +87,7 @@ export async function listReposForExecutionHost(
if (!provider) {
return rejected('unavailable')
}
const matchingRepos = getConsistentRepoCatalogForHost(store.getRepos(), parsedHost)
const matchingRepos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost)
if (!matchingRepos) {
return rejected('rejected')
}
+2 -1
View File
@@ -14,9 +14,10 @@ import {
parseProjectGroupIpcArgs
} from './repo-ipc-arg-schemas'
import { activeNestedRepoScans, runNestedRepoScanForIpc } from './nested-repo-scan-ipc'
import { visibleProjectGroups } from '../../ssh/orcad-retained-source'
export function registerProjectGroupHandlers(mainWindow: BrowserWindow, store: Store): void {
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
ipcMain.handle('projectGroups:list', () => visibleProjectGroups(store))
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
const args = parseProjectGroupIpcArgs(
@@ -12,6 +12,7 @@ import {
type FolderWorkspaceHostState
} from '../../../shared/folder-workspace-execution-host'
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../shared/project-group-types'
import type { Repo } from '../../../shared/repo-types'
export function repoBelongsToOrcadSource(repo: Repo, targetId: string): boolean {
@@ -22,6 +23,17 @@ export function repoBelongsToOrcadSource(repo: Repo, targetId: string): boolean
return host?.kind === 'ssh' && host.targetId === targetId
}
export function projectGroupBelongsToOrcadSource(
group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>,
targetId: string
): boolean {
if (group.connectionId === targetId) {
return true
}
const host = parseExecutionHostId(group.executionHostId)
return host?.kind === 'ssh' && host.targetId === targetId
}
/** Folder workspace ids the target owns in `state`; read before any of its repos are removed. */
export function orcadSourceFolderWorkspaceIds(
state: FolderWorkspaceHostState,
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import type { SshTarget } from '../../shared/ssh-types'
import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission'
import type { Store } from '../persistence'
vi.mock('./ssh-provider-authority', () => ({ isCurrentSshProviderAuthority: () => true }))
const provider = {}
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: () => provider }))
const { visibleProjectGroups } = await import('./orcad-retained-source')
const { listReposForExecutionHost } = await import('../ipc/repos/host-repo-catalog-snapshot')
const FENCED: SshTarget = {
id: 'ssh-box',
label: 'Box',
host: 'box.example.com',
port: 22,
username: 'me',
orcadFence: { environmentId: 'env-1' }
}
function group(id: string, connectionId: string | null): ProjectGroup {
return {
id,
name: id,
parentPath: '/srv',
parentGroupId: null,
connectionId,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
const sourceRepo: Repo = {
id: 'repo-1',
path: '/srv/app',
displayName: 'App',
badgeColor: '#737373',
addedAt: 1,
kind: 'git',
connectionId: FENCED.id
}
function catalog(targets: SshTarget[]): Store {
const groups = [group('source-group', FENCED.id), group('local-group', null)]
const folders: FolderWorkspace[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lists read only these four getters.
return {
getSshTargets: () => targets,
getRepos: () => [sourceRepo],
getProjectGroups: () => groups,
getFolderWorkspaces: () => folders
} as unknown as Store
}
describe('lists while a converted host keeps its source rows', () => {
it('hides the host own project groups, but not a local group', () => {
expect(visibleProjectGroups(catalog([FENCED])).map((entry) => entry.id)).toEqual([
'local-group'
])
})
it('shows them again once an older build changed the host', () => {
const changed = { ...FENCED, orcadFence: { environmentId: 'env-1', sourceChangedAt: 'x' } }
expect(visibleProjectGroups(catalog([changed])).map((entry) => entry.id)).toEqual([
'source-group',
'local-group'
])
})
it('leaves the source repos out of the host catalog the SSH bridge hydrates from', async () => {
const authority: unknown = { targetId: FENCED.id, providerEpoch: 'e1', connectionGeneration: 1 }
if (!isAdmissibleDirectSshAuthority(authority)) {
throw new Error('fixture authority rejected')
}
const snapshot = await listReposForExecutionHost(catalog([FENCED]), {
executionHostId: `ssh:${FENCED.id}`,
expectedAuthority: authority
})
expect(snapshot).toMatchObject({ authoritative: true, repos: [] })
})
})
+16
View File
@@ -8,6 +8,7 @@
*/
import { createHash } from 'node:crypto'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { OrcadMigrationCatalogPayload } from '../../shared/orcad-migration-manifest'
import {
isRetainedOrcadMigrationSourceCutover,
@@ -20,6 +21,7 @@ import type { Store } from '../persistence'
import { collectOrcadMigrationSourceCatalog } from '../persistence/migrating-orcad-catalog/orcad-source-catalog'
import {
orcadSourceFolderWorkspaceIds,
projectGroupBelongsToOrcadSource,
repoBelongsToOrcadSource
} from '../persistence/migrating-orcad-catalog/orcad-source-ownership'
import { findOrcadMigrationSourceCutoverForTarget } from './orcad-migration-cutover-journal'
@@ -45,6 +47,20 @@ export function visibleRepos(store: CatalogStore & Pick<Store, 'getSshTargets'>)
)
}
/** Hides only groups the host owns; a local group holding one of its projects stays. */
export function visibleProjectGroups(
store: Pick<Store, 'getProjectGroups' | 'getSshTargets'>
): ProjectGroup[] {
const hidden = hiddenRetainedSourceTargetIds(store.getSshTargets())
const groups = store.getProjectGroups()
if (hidden.length === 0) {
return groups
}
return groups.filter(
(group) => !hidden.some((targetId) => projectGroupBelongsToOrcadSource(group, targetId))
)
}
export function visibleFolderWorkspaces(
store: CatalogStore & Pick<Store, 'getSshTargets'>
): FolderWorkspace[] {
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeFileClient from '@/runtime/runtime-file-client'
import { useAppStore } from '@/store'
import { makeWorktree } from '@/store/slices/store-test-helpers'
import type { FileContent } from './editor-panel-content-types'
import {
useEditorPanelFileContentLoader,
type EditorPanelFileContentLoader
} from './useEditorPanelFileContentLoader'
const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn() }))
vi.mock('@/runtime/runtime-file-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeFileClient>()),
readRuntimeFileContent: mocks.readRuntimeFileContent
}))
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const WORKTREE = 'repo-1::/root/repo'
const FILE_PATH = '/root/repo/README.md'
/** The workspace now lives on its managed server; the tab was saved by the relay era, unstamped. */
function seedConvertedWorkspace(): void {
useAppStore.setState(useAppStore.getInitialState(), true)
useAppStore.setState({
activeWorktreeId: WORKTREE,
repos: [
{
id: 'repo-1',
path: '/root/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 1,
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'repo-1': [
makeWorktree({
id: WORKTREE,
repoId: 'repo-1',
path: '/root/repo',
hostId: 'runtime:env-1',
runtimeOwnerEnvironmentId: 'env-1'
})
]
}
})
}
function openRestored(filePath: string): string {
return useAppStore.getState().openFile(
{
filePath,
relativePath: filePath.slice('/root/repo/'.length),
worktreeId: WORKTREE,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
}
describe('a restored tab whose workspace moved to its managed server', () => {
let container: HTMLDivElement
let root: Root
let load: EditorPanelFileContentLoader | null
let fileContents: Record<string, FileContent>
beforeEach(() => {
mocks.readRuntimeFileContent.mockReset()
mocks.readRuntimeFileContent.mockResolvedValue({ content: '# repo', isBinary: false })
fileContents = {}
load = null
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
function LoaderHarness(): null {
load = useEditorPanelFileContentLoader({
fileLoadRetryAttemptsRef: { current: {} },
fileReadGenerationCounterRef: { current: 0 },
fileReadGenerationRef: { current: {} },
openFilesRef: {
get current() {
return useAppStore.getState().openFiles
}
},
outstandingFileReadsRef: { current: {} },
setFileContents: (updater) => {
fileContents = typeof updater === 'function' ? updater(fileContents) : updater
}
})
return null
}
act(() => root.render(<LoaderHarness />))
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('takes the server as its owner in place, then reads the file there', async () => {
seedConvertedWorkspace()
const restoredId = openRestored(FILE_PATH)
await act(async () => load!(FILE_PATH, restoredId, WORKTREE, 'README.md'))
const state = useAppStore.getState()
expect(state.openFiles).toHaveLength(1)
const [file] = state.openFiles
expect(file).toMatchObject({ worktreeId: WORKTREE, runtimeEnvironmentId: 'env-1' })
const tabs = state.unifiedTabsByWorktree[WORKTREE] ?? []
expect(tabs.map((tab) => tab.entityId)).toEqual([file.id])
// Same group, same slot: re-owning is not a second tab.
expect((state.groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder)).toEqual([
tabs[0]?.id
])
await act(async () => load!(FILE_PATH, file.id, WORKTREE, 'README.md'))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1)
expect(mocks.readRuntimeFileContent.mock.calls[0]?.[0]).toMatchObject({
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: WORKTREE
})
expect(fileContents[file.id]).toMatchObject({ content: '# repo' })
})
it('keeps its place and focus among the other restored tabs', async () => {
seedConvertedWorkspace()
const readmeId = openRestored(FILE_PATH)
openRestored('/root/repo/NOTES.md')
const before = useAppStore.getState()
const tabIdsBefore = before.unifiedTabsByWorktree[WORKTREE]?.map((tab) => tab.id)
const activeTabBefore = before.groupsByWorktree[WORKTREE]?.[0]?.activeTabId
await act(async () => load!(FILE_PATH, readmeId, WORKTREE, 'README.md'))
const after = useAppStore.getState()
expect(after.unifiedTabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(tabIdsBefore)
expect(after.groupsByWorktree[WORKTREE]?.[0]?.activeTabId).toBe(activeTabBefore)
expect(after.openFiles.map((file) => file.runtimeEnvironmentId ?? null)).toEqual([
'env-1',
null
])
})
})
@@ -0,0 +1,38 @@
import type { OpenFile } from '@/store/slices/editor'
import type { AppState } from '@/store/types'
import { hasOpenFileExecutionHostEvidence } from '@/lib/unified-tab-host-ownership'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
findRuntimeWorkspaceFileRoute,
type RuntimeWorkspaceFileRoute
} from '@/lib/runtime-workspace-file-route'
export type RestoredEditorWorkspaceRuntimeOwner = {
route: RuntimeWorkspaceFileRoute
runtimeEnvironmentId: string
}
/**
* The managed server a restored tab now belongs to, when its workspace moved there from an SSH
* host. Such tabs name no host because the relay era derived it from the workspace, so they would
* keep reading through the relay; they take the workspace's server instead, as a fresh open does.
*/
export function findRestoredEditorWorkspaceRuntimeOwner(
state: AppState,
file: OpenFile,
worktreeId: string | undefined
): RestoredEditorWorkspaceRuntimeOwner | null {
if (!worktreeId || file.worktreeId !== worktreeId || hasOpenFileExecutionHostEvidence(file)) {
return null
}
const runtimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId)
// Why skip the focused server: its unstamped tabs already read and save there.
if (
!runtimeEnvironmentId ||
runtimeEnvironmentId === state.settings?.activeRuntimeEnvironmentId?.trim()
) {
return null
}
const route = findRuntimeWorkspaceFileRoute(state, runtimeEnvironmentId, file.filePath)
return route?.worktreeId === worktreeId ? { route, runtimeEnvironmentId } : null
}
@@ -19,6 +19,8 @@ import {
} from './editor-panel-content-types'
import type { EditorPanelContentLoadOptions } from './useEditorPanelExternalContentEvents'
import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner'
import { findRestoredEditorWorkspaceRuntimeOwner } from './restored-editor-workspace-runtime-owner'
import type { RuntimeWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
const inFlightFileReads = new Map<string, InFlightContentRead<FileContent>>()
@@ -99,6 +101,41 @@ export function useEditorPanelFileContentLoader({
readConnectionId = connectionId
let readWorktreeId = worktreeId
let readRelativePath = restoredOpenFile?.relativePath ?? relativePath
// Re-owning re-keys the tab, which starts a fresh load under the new owner.
const reownRestoredFile = async (
route: RuntimeWorkspaceFileRoute,
runtimeEnvironmentId: string | null
): Promise<void> => {
const migration = await migrateRestoredEditorFileOwner(id, route, runtimeEnvironmentId)
if (!migration.ok) {
throw new Error(
migration.reason === 'collision'
? 'The sibling file is already open; close one tab before restoring it.'
: 'The sibling file owner changed while the tab was restoring.'
)
}
fileReadGenerationRef.current[id] = ++fileReadGenerationCounterRef.current
setFileContents((prev) => {
const next = { ...prev }
delete next[id]
return next
})
}
const workspaceRuntimeOwner =
restoredOpenFile && !isLiveTailLogTab
? findRestoredEditorWorkspaceRuntimeOwner(
useAppStore.getState(),
restoredOpenFile,
worktreeId
)
: null
if (workspaceRuntimeOwner) {
await reownRestoredFile(
workspaceRuntimeOwner.route,
workspaceRuntimeOwner.runtimeEnvironmentId
)
return
}
if (
resolvedConnectionId === undefined &&
!readSettings?.activeRuntimeEnvironmentId?.trim() &&
@@ -131,24 +168,7 @@ export function useEditorPanelFileContentLoader({
: LOCAL_EXECUTION_HOST_ID
const route = findWorkspaceFileRoute(currentState, executionHostId, filePath)
if (route && route.worktreeId !== worktreeId) {
const migration = await migrateRestoredEditorFileOwner(
id,
route,
runtimeEnvironmentId ?? null
)
if (!migration.ok) {
throw new Error(
migration.reason === 'collision'
? 'The sibling file is already open; close one tab before restoring it.'
: 'The sibling file owner changed while the tab was restoring.'
)
}
fileReadGenerationRef.current[id] = ++fileReadGenerationCounterRef.current
setFileContents((prev) => {
const next = { ...prev }
delete next[id]
return next
})
await reownRestoredFile(route, runtimeEnvironmentId ?? null)
return
}
if (runtimeEnvironmentId && !route) {
@@ -48,7 +48,8 @@ vi.mock('./migrate-restored-editor-file-owner', () => ({
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: vi.fn(() => 'local')
getExecutionHostIdForWorktree: vi.fn(() => 'local'),
getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(() => null)
}))
vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } }))
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore, makeWorktree } from '../../store/slices/store-test-helpers'
const store = createTestStore()
vi.mock('../../store', () => ({ useAppStore: store }))
vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { error: vi.fn() }) }))
const { applySshManagedServerTransition } = await import('./ssh-managed-server-state-effects')
const repo: Repo = {
id: 'repo-1',
path: '/root/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 1
}
const group: ProjectGroup = {
id: 'group-1',
name: 'Folders',
parentPath: '/root',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folder: FolderWorkspace = {
id: 'folder-1',
projectGroupId: 'group-1',
name: 'notes',
folderPath: '/root/notes',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const worktreeId = 'repo-1::/root/repo'
function serverReply(args: RuntimeEnvironmentCallRequest): unknown {
const results: Record<string, unknown> = {
'repo.list': { repos: [repo] },
'projectGroup.list': { groups: [group] },
'folderWorkspace.list': { folderWorkspaces: [folder] }
}
return (
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc',
ok: true,
result: results[args.method] ?? { projects: [], setups: [] },
_meta: { runtimeId: 'orcad' }
}
)
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
// Main hides the retained relay-era rows, so its own lists come back empty.
vi.stubGlobal('window', {
api: {
repos: { list: vi.fn(async () => []) },
projects: { list: vi.fn(async () => []), listHostSetups: vi.fn(async () => []) },
projectGroups: { list: vi.fn(async () => []) },
folderWorkspaces: { list: vi.fn(async () => []) },
runtimeEnvironments: {
list: vi.fn(async () => [{ id: 'env-1', name: 'Box server' }]),
call: vi.fn(async (args: RuntimeEnvironmentCallRequest) => serverReply(args))
}
},
dispatchEvent: vi.fn()
})
// What the renderer held for the host before it converted.
store.setState({
repos: [{ ...repo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }],
worktreesByRepo: {
'repo-1': [
makeWorktree({ id: worktreeId, repoId: 'repo-1', path: '/root/repo', hostId: 'ssh:ssh-1' })
]
},
projectGroups: [{ ...group, connectionId: 'ssh-1' }],
folderWorkspaces: [folder],
runtimeEnvironments: []
})
})
describe('a host that just moved to its managed server', () => {
it('shows the server copies only, under the server name', async () => {
applySshManagedServerTransition('ssh-1', undefined, {
kind: 'managed',
environmentId: 'env-1'
})
await vi.waitFor(() =>
expect(store.getState().repos.map((entry) => entry.executionHostId)).toEqual([
'runtime:env-1'
])
)
const state = store.getState()
expect(state.worktreesByRepo['repo-1']).toEqual([])
expect(state.runtimeEnvironments.map((entry) => entry.name)).toEqual(['Box server'])
expect(state.projectGroups.map((entry) => entry.executionHostId)).toEqual(['runtime:env-1'])
expect(state.folderWorkspaces.map((entry) => entry.executionHostId)).toEqual(['runtime:env-1'])
})
})
@@ -8,6 +8,7 @@ import {
moveSshHostFromToast
} from '@/ssh/ssh-managed-server-move'
import { useAppStore } from '../../store'
import { withoutConvertedSshHostRows } from '../../store/repos/converted-ssh-host-rows'
type ManagedServerStatus = SshConnectionState['managedServer']
@@ -20,8 +21,9 @@ export function applySshManagedServerTransition(
next?.kind === 'managed' &&
(previous?.kind !== 'managed' || previous.environmentId !== next.environmentId)
) {
// Why all hosts: a host that just converted brings a new server whose projects must load.
void useAppStore.getState().fetchReposForAllHosts()
void loadManagedServerCatalogs(targetId, next.environmentId).catch((error: unknown) =>
console.warn('[ssh] Could not load the managed server catalogs:', error)
)
return
}
if (isNewMoveOffer(previous, next) && canMoveSshHostToManagedServer()) {
@@ -47,6 +49,28 @@ export function applySshManagedServerTransition(
}
}
/** Loads a newly managed host's server the way startup does, then drops its relay-era rows. */
async function loadManagedServerCatalogs(targetId: string, environmentId: string): Promise<void> {
const store = useAppStore.getState()
try {
// Why: host badges read server names from this catalog, which a conversion does not refresh.
store.setRuntimeEnvironments(await window.api.runtimeEnvironments.list())
void store.refreshRuntimeEnvironmentStatus(environmentId)
} catch (error) {
console.warn('[ssh] Could not refresh the managed server list:', error)
}
// Why all hosts: a host that just converted brings a new server whose projects must load.
await store.fetchReposForAllHosts()
// Why groups before folders: folder workspaces are owned through their project groups.
await store.fetchProjectGroupsForAllHosts()
await store.fetchFolderWorkspacesForAllHosts()
// Why gated: startup runs its own scan once every host's catalog is in.
if (useAppStore.getState().startupWorktreeRefreshCompleted) {
await store.fetchAllWorktrees()
}
useAppStore.setState((state) => withoutConvertedSshHostRows(state, targetId))
}
/** Main marks only the first live-terminals stop per host per app version with `offerMove`. */
function isNewMoveOffer(
previous: ManagedServerStatus,
@@ -0,0 +1,48 @@
import type { AppState } from '../types'
import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
type ConvertedHostRows = Pick<AppState, 'repos' | 'worktreesByRepo' | 'detectedWorktreesByRepo'>
/**
* Drops a converted SSH host's relay-era project and worktree rows from the renderer. Main already
* hides them, but a local catalog refresh keeps SSH rows it no longer lists, so they would sit next
* to the managed server's copies under the same ids. Session and terminal state are left alone.
*/
export function withoutConvertedSshHostRows(
state: ConvertedHostRows,
targetId: string
): Partial<ConvertedHostRows> {
const hostId = toSshExecutionHostId(targetId)
const repos = state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId)
const worktreesByRepo = withoutHostRows(state.worktreesByRepo, hostId)
let detectedChanged = false
const detectedWorktreesByRepo = Object.fromEntries(
Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => {
const worktrees = result.worktrees.filter((worktree) => worktree.hostId !== hostId)
if (worktrees.length === result.worktrees.length) {
return [repoId, result]
}
detectedChanged = true
return [repoId, { ...result, worktrees }]
})
)
return {
...(repos.length === state.repos.length ? {} : { repos }),
...(worktreesByRepo === state.worktreesByRepo ? {} : { worktreesByRepo }),
...(detectedChanged ? { detectedWorktreesByRepo } : {})
}
}
function withoutHostRows<T extends { hostId?: string }>(
rowsByRepo: Record<string, T[]>,
hostId: string
): Record<string, T[]> {
let changed = false
const next: Record<string, T[]> = {}
for (const [repoId, rows] of Object.entries(rowsByRepo)) {
const kept = rows.filter((row) => row.hostId !== hostId)
changed ||= kept.length !== rows.length
next[repoId] = kept.length === rows.length ? rows : kept
}
return changed ? next : rowsByRepo
}
@@ -0,0 +1,64 @@
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { AppState } from '../../../types'
type InPlaceTabState = Pick<
AppState,
| 'activeFileIdByWorktree'
| 'activeGroupIdByWorktree'
| 'activeTabTypeByWorktree'
| 'groupsByWorktree'
| 'layoutByWorktree'
| 'tabBarOrderByWorktree'
| 'unifiedTabsByWorktree'
>
/**
* A restored tab that only changes host inside its own workspace keeps its group, position and
* focus; only its ids change. The cross-workspace move would append it to a group as a new tab.
*/
export function rekeyRestoredEditorTabsInPlace(
s: InPlaceTabState,
worktreeId: string,
fileIdMigrations: ReadonlyMap<string, string>,
tabIdMigrations: ReadonlyMap<string, string>,
executionHostId: ExecutionHostId
): InPlaceTabState {
const tabId = (id: string): string => tabIdMigrations.get(id) ?? id
const fileId = (id: string): string => fileIdMigrations.get(id) ?? id
const activeFileId = s.activeFileIdByWorktree[worktreeId]
return {
activeFileIdByWorktree: {
...s.activeFileIdByWorktree,
[worktreeId]: activeFileId ? fileId(activeFileId) : activeFileId
},
activeGroupIdByWorktree: s.activeGroupIdByWorktree,
activeTabTypeByWorktree: s.activeTabTypeByWorktree,
layoutByWorktree: s.layoutByWorktree,
groupsByWorktree: {
...s.groupsByWorktree,
[worktreeId]: (s.groupsByWorktree[worktreeId] ?? []).map((group) => ({
...group,
activeTabId: group.activeTabId ? tabId(group.activeTabId) : group.activeTabId,
tabOrder: group.tabOrder.map(tabId),
...(group.recentTabIds ? { recentTabIds: group.recentTabIds.map(tabId) } : {})
}))
},
unifiedTabsByWorktree: {
...s.unifiedTabsByWorktree,
[worktreeId]: (s.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) =>
tabIdMigrations.has(tab.id) || fileIdMigrations.has(tab.entityId)
? {
...tab,
id: tabId(tab.id),
entityId: fileId(tab.entityId),
...(tab.executionHostId ? { executionHostId } : {})
}
: tab
)
},
tabBarOrderByWorktree: {
...s.tabBarOrderByWorktree,
[worktreeId]: (s.tabBarOrderByWorktree[worktreeId] ?? []).map(fileId)
}
}
}
@@ -14,6 +14,7 @@ import type {
RestoredEditorOwnerResult
} from '../types/restored-editor-owner'
import { resolveRestoredEditorOwnerDestination } from './restored-editor-owner-destination'
import { rekeyRestoredEditorTabsInPlace } from './restored-editor-owner-in-place'
export function buildRestoredEditorOwnerTransition(
args: RestoredEditorOwnerMigration,
@@ -170,6 +171,27 @@ export function buildRestoredEditorOwnerTransition(
delete nextActiveGroupIdByWorktree[sourceWorktreeId]
}
nextActiveGroupIdByWorktree[targetWorktreeId] = targetGroupId
const tabState =
sourceWorktreeId === targetWorktreeId
? rekeyRestoredEditorTabsInPlace(
s,
targetWorktreeId,
migrations,
tabIdMigration,
args.targetExecutionHostId
)
: {
activeFileIdByWorktree: nextActiveFileIdByWorktree,
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
activeTabTypeByWorktree: {
...s.activeTabTypeByWorktree,
[targetWorktreeId]: 'editor' as const
},
groupsByWorktree: nextGroupsByWorktree,
layoutByWorktree: nextLayoutByWorktree,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
unifiedTabsByWorktree: nextUnifiedTabsByWorktree
}
assignResult({ ok: true, fileId: newFileId })
return {
@@ -213,16 +235,7 @@ export function buildRestoredEditorOwnerTransition(
migrations
),
activeFileId: s.activeFileId ? (migrations.get(s.activeFileId) ?? s.activeFileId) : null,
activeFileIdByWorktree: nextActiveFileIdByWorktree,
activeTabTypeByWorktree: {
...s.activeTabTypeByWorktree,
[targetWorktreeId]: 'editor'
},
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
groupsByWorktree: nextGroupsByWorktree,
layoutByWorktree: nextLayoutByWorktree,
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
...tabState,
...(s.pendingEditorReveal?.fileId && migrations.has(s.pendingEditorReveal.fileId)
? {
pendingEditorReveal: {