mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
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:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user