From b4f55be5f85726e6633244467efa3f1ce6555653 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 4 Oct 2026 03:17:16 -0700 Subject: [PATCH] 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 Co-authored-by: Claude --- src/main/ipc/repos-remote-test-harness.ts | 6 +- .../ipc/repos/host-repo-catalog-snapshot.ts | 5 +- src/main/ipc/repos/project-group-handlers.ts | 3 +- .../orcad-source-ownership.ts | 12 ++ .../ssh/orcad-retained-source-lists.test.ts | 89 ++++++++++ src/main/ssh/orcad-retained-source.ts | 16 ++ ...ed-editor-workspace-runtime-owner.test.tsx | 152 ++++++++++++++++++ ...restored-editor-workspace-runtime-owner.ts | 38 +++++ .../editor/useEditorPanelFileContentLoader.ts | 56 ++++--- ...torPanelRemoteSiblingContentState.test.tsx | 3 +- ...-managed-server-converted-catalogs.test.ts | 118 ++++++++++++++ .../ssh-managed-server-state-effects.ts | 28 +++- .../store/repos/converted-ssh-host-rows.ts | 48 ++++++ .../actions/restored-editor-owner-in-place.ts | 64 ++++++++ .../restored-editor-owner-transition.ts | 33 ++-- 15 files changed, 635 insertions(+), 36 deletions(-) create mode 100644 src/main/ssh/orcad-retained-source-lists.test.ts create mode 100644 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx create mode 100644 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts create mode 100644 src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts create mode 100644 src/renderer/src/store/repos/converted-ssh-host-rows.ts create mode 100644 src/renderer/src/store/slices/editor/actions/restored-editor-owner-in-place.ts diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 075b911ad50..3e7cfe783c2 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -25,7 +25,8 @@ export type ReposIpcMocks = { | 'updateProjectGroup' | 'deleteProjectGroup' | 'moveProjectToGroup' - | 'getSshTarget', + | 'getSshTarget' + | 'getSshTargets', ReposIpcSpy > & { updateRepo: Mock<(repoId: string, updates: Record) => 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), diff --git a/src/main/ipc/repos/host-repo-catalog-snapshot.ts b/src/main/ipc/repos/host-repo-catalog-snapshot.ts index e6a55441c28..3e16cf6245b 100644 --- a/src/main/ipc/repos/host-repo-catalog-snapshot.ts +++ b/src/main/ipc/repos/host-repo-catalog-snapshot.ts @@ -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') } diff --git a/src/main/ipc/repos/project-group-handlers.ts b/src/main/ipc/repos/project-group-handlers.ts index 257e14c935a..25f9c4506a4 100644 --- a/src/main/ipc/repos/project-group-handlers.ts +++ b/src/main/ipc/repos/project-group-handlers.ts @@ -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( diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts index a7973136ee7..af56f3408e3 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-ownership.ts @@ -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, + 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, diff --git a/src/main/ssh/orcad-retained-source-lists.test.ts b/src/main/ssh/orcad-retained-source-lists.test.ts new file mode 100644 index 00000000000..33a333d539b --- /dev/null +++ b/src/main/ssh/orcad-retained-source-lists.test.ts @@ -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: [] }) + }) +}) diff --git a/src/main/ssh/orcad-retained-source.ts b/src/main/ssh/orcad-retained-source.ts index f9c178dcdc1..fd408bf39df 100644 --- a/src/main/ssh/orcad-retained-source.ts +++ b/src/main/ssh/orcad-retained-source.ts @@ -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) ) } +/** Hides only groups the host owns; a local group holding one of its projects stays. */ +export function visibleProjectGroups( + store: Pick +): 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 ): FolderWorkspace[] { diff --git a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx new file mode 100644 index 00000000000..b92e8eb2de6 --- /dev/null +++ b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx @@ -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()), + 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 + + 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()) + }) + + 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 + ]) + }) +}) diff --git a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts new file mode 100644 index 00000000000..31489b42e15 --- /dev/null +++ b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts @@ -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 +} diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index e7fdfc88754..36819661100 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -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>() @@ -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 => { + 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) { diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx index 08fcc4df79a..3057c7a8ac8 100644 --- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -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 } })) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts new file mode 100644 index 00000000000..d409b5f5bde --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts @@ -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 = { + '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']) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index d37b5ccdde2..37d07ac4bab 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -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 { + 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, diff --git a/src/renderer/src/store/repos/converted-ssh-host-rows.ts b/src/renderer/src/store/repos/converted-ssh-host-rows.ts new file mode 100644 index 00000000000..460db60fd0a --- /dev/null +++ b/src/renderer/src/store/repos/converted-ssh-host-rows.ts @@ -0,0 +1,48 @@ +import type { AppState } from '../types' +import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' + +type ConvertedHostRows = Pick + +/** + * 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 { + 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( + rowsByRepo: Record, + hostId: string +): Record { + let changed = false + const next: Record = {} + 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 +} diff --git a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-in-place.ts b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-in-place.ts new file mode 100644 index 00000000000..40a0cf23db4 --- /dev/null +++ b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-in-place.ts @@ -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, + tabIdMigrations: ReadonlyMap, + 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) + } + } +} diff --git a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts index ab28554b7a2..c0fefd42bd8 100644 --- a/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts +++ b/src/renderer/src/store/slices/editor/actions/restored-editor-owner-transition.ts @@ -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: {