diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index ab134697b..13260d457 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -84,6 +84,33 @@ function installAnimationFrameController(): { } } +function installMutationObserverController(): { notify: () => void } { + const callbacks = new Map() + class ControlledMutationObserver implements MutationObserver { + constructor(callback: MutationCallback) { + callbacks.set(this, callback) + } + + observe(): void {} + + disconnect(): void { + callbacks.delete(this) + } + + takeRecords(): MutationRecord[] { + return [] + } + } + vi.stubGlobal('MutationObserver', ControlledMutationObserver) + return { + notify() { + for (const [observer, callback] of callbacks) { + callback([], observer) + } + } + } +} + async function flushPortalFramesUntil( frames: ReturnType, settled: () => boolean @@ -347,6 +374,7 @@ describe('Activity portal pane switching', () => { it('releases a latched readiness once the terminal attaches', async () => { const frames = installAnimationFrameController() + const mutations = installMutationObserverController() const target = document.createElement('div') document.body.append(target) const buildRoot = (mode: 'hidden' | 'sibling' | 'ready'): void => { @@ -402,7 +430,7 @@ describe('Activity portal pane switching', () => { const statusesBefore = statuses.length await act(async () => { buildRoot(mode) - await Promise.resolve() + mutations.notify() }) expect(await flushPortalReadiness(frames)).toBe(true) if (mode !== 'sibling') { @@ -426,7 +454,7 @@ describe('Activity portal pane switching', () => { for (let attempt = 0; attempt < PORTAL_READY_REAPPLY_ATTEMPTS && !sawReady; attempt += 1) { await act(async () => { buildRoot('ready') - await Promise.resolve() + mutations.notify() }) expect(await flushPortalReadiness(frames)).toBe(true) await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'ready') diff --git a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts index 95fcdeda8..3f36fb046 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.test.ts @@ -56,4 +56,35 @@ describe('file explorer add project action', () => { connectionId: 'ssh-target-1' }) }) + + it('routes an execution-host-only SSH project subfolder to its SSH target', () => { + expect( + buildAddProjectFromFolderModalData(folderNode, { + ...folderRepo, + executionHostId: 'ssh:ssh-target-1' + }) + ).toEqual({ + folderPath: '/projects/child-project', + connectionId: 'ssh-target-1' + }) + }) + + it('routes a local project subfolder explicitly to local', () => { + expect(buildAddProjectFromFolderModalData(folderNode, folderRepo)).toEqual({ + folderPath: '/projects/child-project', + runtimeEnvironmentId: null + }) + }) + + it("routes a runtime project subfolder to the repo's runtime", () => { + expect( + buildAddProjectFromFolderModalData(folderNode, { + ...folderRepo, + executionHostId: 'runtime:runtime-a' + }) + ).toEqual({ + folderPath: '/projects/child-project', + runtimeEnvironmentId: 'runtime-a' + }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts index 57d317abb..b8af63bcb 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-add-project-action.ts @@ -1,10 +1,12 @@ import { isFolderRepo } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/types' import type { TreeNode } from './file-explorer-types' export type AddProjectFromFolderModalData = { folderPath: string connectionId?: string + runtimeEnvironmentId?: string | null } export function canShowAddAsProjectAction(node: TreeNode, activeRepo: Repo | null): boolean { @@ -15,8 +17,13 @@ export function buildAddProjectFromFolderModalData( node: TreeNode, activeRepo: Repo ): AddProjectFromFolderModalData { + // Why: subfolder paths must stay on their owning repo host, not the mutable global selection. + const host = parseExecutionHostId(getRepoExecutionHostId(activeRepo)) + if (host?.kind === 'ssh') { + return { folderPath: node.path, connectionId: host.targetId } + } return { folderPath: node.path, - ...(activeRepo.connectionId ? { connectionId: activeRepo.connectionId } : {}) + runtimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null } } diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx index ddff8f752..7310d450a 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx @@ -150,7 +150,9 @@ describe('AddProjectFromFolderDialog', () => { renderToStaticMarkup() await clickAddProject() - expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child') + expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child', 'git', { + runtimeEnvironmentId: null + }) expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true, executionHostId: 'local' @@ -179,13 +181,44 @@ describe('AddProjectFromFolderDialog', () => { renderToStaticMarkup() await clickAddProject() - expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child') + expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/projects/child', 'git', { + runtimeEnvironmentId: null + }) expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { folderPath: '/projects/child' }) expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() }) + it("routes a runtime project's subfolder and completion to the owning runtime", async () => { + const repo = makeRepo({ id: 'runtime-repo', executionHostId: 'runtime:runtime-a' }) + mocks.state.modalData = { + folderPath: '/srv/projects/child', + runtimeEnvironmentId: 'runtime-a' + } + mocks.state.addRepoPath.mockResolvedValue(repo) + const { default: AddProjectFromFolderDialog } = await import('./AddProjectFromFolderDialog') + + renderToStaticMarkup() + await clickAddProject() + + expect(mocks.state.addRepoPath).toHaveBeenCalledWith('/srv/projects/child', 'git', { + runtimeEnvironmentId: 'runtime-a' + }) + expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith(repo.id, { + requireAuthoritative: true, + executionHostId: 'runtime:runtime-a' + }) + expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({ + repoId: repo.id, + source: 'runtime_server_path', + selectedPath: '/srv/projects/child', + executionHostId: 'runtime:runtime-a', + closeModal: mocks.state.closeModal, + setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace + }) + }) + it('adds an SSH Git folder through the remote repo import path', async () => { const repo = makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }) mocks.state.modalData = { diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx index 78a0f2b02..f3ef3b43c 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -39,6 +39,8 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo const [previousOpen, setPreviousOpen] = useState(isOpen) const folderPath = typeof modalData.folderPath === 'string' ? modalData.folderPath : '' const connectionId = typeof modalData.connectionId === 'string' ? modalData.connectionId : '' + const runtimeEnvironmentId = + typeof modalData.runtimeEnvironmentId === 'string' ? modalData.runtimeEnvironmentId : null if (isOpen !== previousOpen) { setPreviousOpen(isOpen) @@ -55,9 +57,12 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo closeModal() openModal('confirm-non-git-folder', { folderPath, - ...(connectionId ? { connectionId } : {}) + ...(connectionId ? { connectionId } : {}), + // Absence === local: NonGitFolderDialog coerces a missing/empty + // runtimeEnvironmentId to null, so omitting the spread signals local. + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) }) - }, [closeModal, connectionId, folderPath, openModal]) + }, [closeModal, connectionId, folderPath, openModal, runtimeEnvironmentId]) const handleConfirm = useCallback(async () => { if (!folderPath || isAdding) { @@ -94,7 +99,7 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo { description: repo.displayName } ) } else { - repo = await addRepoPath(folderPath) + repo = await addRepoPath(folderPath, 'git', { runtimeEnvironmentId }) } if (!mountedRef.current || gen !== addGenRef.current) { @@ -109,14 +114,18 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo } // Why: after the repo is already added, a non-authoritative refresh // should still close onto the project row instead of trapping the user. - const ownerOptions = worktreeRefreshOptions(null, connectionId) + const ownerOptions = worktreeRefreshOptions(runtimeEnvironmentId, connectionId) await fetchWorktrees(repo.id, ownerOptions) if (!mountedRef.current || gen !== addGenRef.current) { return } await finishProjectAddWithDefaultCheckout({ repoId: repo.id, - source: connectionId ? 'ssh_remote_path' : 'local_folder_picker', + source: connectionId + ? 'ssh_remote_path' + : runtimeEnvironmentId + ? 'runtime_server_path' + : 'local_folder_picker', selectedPath: folderPath, executionHostId: ownerOptions.executionHostId, closeModal, @@ -147,6 +156,7 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo isAdding, mountedRef, openNonGitConfirmation, + runtimeEnvironmentId, setHideDefaultBranchWorkspace ]) diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts index 0f5ad7cfe..8b4b42d8d 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts @@ -221,6 +221,26 @@ describe('finishProjectAddWithDefaultCheckout', () => { }) }) + it('activates a runtime-owned checkout even when its physical host is private SSH', async () => { + const runtimeMain = makeWorktree({ + id: 'repo-1::runtime-ssh', + hostId: 'ssh:private-target', + runtimeOwnerEnvironmentId: 'env-1' + }) + mocks.state.worktreesByRepo = { 'repo-1': [runtimeMain] } + + await openProjectDefaultCheckout({ + repoId: 'repo-1', + source: 'runtime_server_path', + executionHostId: 'runtime:env-1', + setHideDefaultBranchWorkspace: vi.fn() + }) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(runtimeMain.id, { + executionHostId: 'runtime:env-1' + }) + }) + it('passes a contained selected path through as the initial terminal cwd', async () => { mocks.state.worktreesByRepo = { 'repo-1': [makeWorktree()] diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.ts index 0c7fc74f7..dabf089a8 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.ts @@ -9,7 +9,7 @@ import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/ty import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' -import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' type DefaultCheckoutHandoffReason = EventProps<'add_repo_default_checkout_handoff'>['reason'] @@ -24,12 +24,21 @@ function getProjectWorktreesForHost( if (!executionHostId) { return [...worktrees] } + const parsedHost = parseExecutionHostId(executionHostId) return worktrees.filter((worktree) => { - if (worktree.hostId) { + if (parsedHost?.kind === 'runtime') { + if (worktree.runtimeOwnerEnvironmentId) { + return worktree.runtimeOwnerEnvironmentId === parsedHost.environmentId + } + // Reachable: a colliding repo id can carry a runtime-qualified hostId with + // no runtimeOwnerEnvironmentId, so match it against the execution host. return worktree.hostId === executionHostId } if (worktree.runtimeOwnerEnvironmentId) { - return executionHostId === `runtime:${encodeURIComponent(worktree.runtimeOwnerEnvironmentId)}` + return false + } + if (worktree.hostId) { + return worktree.hostId === executionHostId } return executionHostId === 'local' }) diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.test.ts b/src/renderer/src/lib/worktree-runtime-owner-index.test.ts new file mode 100644 index 000000000..0de6397e3 --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner-index.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { findIndexedWorktreeOwnerForHost } from './worktree-runtime-owner-index' + +describe('worktree runtime owner index', () => { + it('indexes paired worktrees by both runtime owner and physical host', () => { + const paired = { + id: 'repo-1::same-id', + repoId: 'repo-1', + hostId: 'ssh:private-target' as const, + runtimeOwnerEnvironmentId: 'hub-a' + } + const directSsh = { + id: 'repo-1::direct', + repoId: 'repo-1', + hostId: 'ssh:direct-target' as const + } + const worktreesByRepo = { 'repo-1': [paired, directSsh] } + + expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, paired.id, 'runtime:hub-a')).toBe( + paired + ) + expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, paired.id, 'ssh:private-target')).toBe( + paired + ) + expect( + findIndexedWorktreeOwnerForHost(worktreesByRepo, directSsh.id, 'ssh:direct-target') + ).toBe(directSsh) + expect( + findIndexedWorktreeOwnerForHost(worktreesByRepo, directSsh.id, 'runtime:hub-a') + ).toBeNull() + }) + + it('fails closed when direct and paired worktrees share a physical host alias', () => { + const direct = { + id: 'same-id', + repoId: 'direct-repo', + hostId: 'ssh:private-target' as const + } + const paired = { + id: 'same-id', + repoId: 'paired-repo', + hostId: 'ssh:private-target' as const, + runtimeOwnerEnvironmentId: 'hub-a' + } + + for (const worktrees of [ + [direct, paired], + [paired, direct] + ]) { + const worktreesByRepo = { repo: worktrees } + expect( + findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'ssh:private-target') + ).toBeNull() + expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'runtime:hub-a')).toBe( + paired + ) + } + }) + + it('fails closed when paired worktrees share a runtime host alias', () => { + const pairedA = { + id: 'same-id', + repoId: 'repo-a', + hostId: 'ssh:private-a' as const, + runtimeOwnerEnvironmentId: 'hub-a' + } + const pairedB = { + id: 'same-id', + repoId: 'repo-b', + hostId: 'ssh:private-b' as const, + runtimeOwnerEnvironmentId: 'hub-a' + } + + for (const worktrees of [ + [pairedA, pairedB], + [pairedB, pairedA] + ]) { + const worktreesByRepo = { repo: worktrees } + expect( + findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'runtime:hub-a') + ).toBeNull() + expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'ssh:private-a')).toBe( + pairedA + ) + expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'ssh:private-b')).toBe( + pairedB + ) + } + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts index c9d2afbc4..25b1af478 100644 --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -156,13 +156,33 @@ function worktreeOwnerIdentity(owner: WorktreeOwnerRecord): string { ]) } -function worktreeOwnerHostId(owner: WorktreeOwnerRecord): ExecutionHostId { - return ( - parseExecutionHostId(owner.hostId)?.id ?? - (owner.runtimeOwnerEnvironmentId - ? toRuntimeExecutionHostId(owner.runtimeOwnerEnvironmentId) - : 'local') - ) +function addWorktreeOwnerIndexEntry( + index: Map, + key: string, + owner: WorktreeOwnerRecord +): void { + const current = index.get(key) + if (!current) { + index.set(key, { kind: 'resolved', owner }) + } else if ( + current.kind === 'resolved' && + worktreeOwnerIdentity(current.owner) !== worktreeOwnerIdentity(owner) + ) { + index.set(key, { kind: 'ambiguous' }) + } +} + +function worktreeOwnerHostIds(owner: WorktreeOwnerRecord): ExecutionHostId[] { + const physicalHostId = parseExecutionHostId(owner.hostId)?.id + const runtimeEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() + if (!runtimeEnvironmentId) { + return [physicalHostId ?? 'local'] + } + const runtimeHostId = toRuntimeExecutionHostId(runtimeEnvironmentId) + // Why: paired HUB worktrees need logical-runtime lookup without losing their physical SSH route. + return physicalHostId && physicalHostId !== runtimeHostId + ? [physicalHostId, runtimeHostId] + : [runtimeHostId] } export function resolveIndexedWorktreeOwner( @@ -178,19 +198,10 @@ export function resolveIndexedWorktreeOwner( for (const worktrees of Object.values(worktreesByRepo)) { for (const worktree of worktrees) { const id = worktree.id - const current = next.get(id) - if (!current) { - next.set(id, { kind: 'resolved', owner: worktree }) - } else if ( - current.kind === 'resolved' && - worktreeOwnerIdentity(current.owner) !== worktreeOwnerIdentity(worktree) - ) { - next.set(id, { kind: 'ambiguous' }) + addWorktreeOwnerIndexEntry(next, id, worktree) + for (const hostId of worktreeOwnerHostIds(worktree)) { + addWorktreeOwnerIndexEntry(next, `${id}\0${hostId}`, worktree) } - next.set(`${id}\0${worktreeOwnerHostId(worktree)}`, { - kind: 'resolved', - owner: worktree - }) } } index = next