From 5ebc4bb2bc98dcc758a72ffa175dd3e06d50133b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:42:59 -0700 Subject: [PATCH] refactor(renderer): split repos store slice (#16191) * refactor(renderer): split repos store slice * test(store): align repos/folder-workspace characterization tests with main's owner-scoped delete + host-qualified visit recency * fix(composer-state): repoint RepoUpdate import to repos/repo-state after split --- config/max-lines-baseline.txt | 1 - .../sidebar/useAddRepoLocalFolderFlow.ts | 2 +- .../worktree-visibility-source-mutation.ts | 2 +- .../composer-state/composer-store-actions.ts | 2 +- src/renderer/src/store/catalog-identity.ts | 46 + .../folder-path-status-actions.ts | 83 + .../folder-workspaces/folder-path-status.ts | 136 + .../folder-workspace-catalog-actions.ts | 142 + .../folder-workspace-catalog.ts | 236 + .../folder-workspace-mutations.ts | 269 ++ .../folder-workspace-routing.ts | 65 + .../src/store/host-catalog-fencing.ts | 62 + .../nested-repository-operations.ts | 130 + .../project-group-catalog-actions.ts | 95 + .../project-groups/project-group-catalog.ts | 68 + .../project-groups/project-group-mutations.ts | 256 + .../project-group-owner-stamping.ts | 17 + .../project-group-removal-state.ts | 39 + .../projects/project-compatibility-core.ts | 242 + .../project-compatibility-host-merge.ts | 226 + .../store/projects/project-host-routing.ts | 135 + .../projects/project-host-setup-actions.ts | 284 ++ .../projects/project-profile-presence.ts | 48 + .../src/store/projects/project-update.ts | 52 + .../repos/all-host-repo-catalog-actions.ts | 199 + src/renderer/src/store/repos/owner-routing.ts | 101 + .../src/store/repos/repo-add-actions.ts | 205 + .../src/store/repos/repo-catalog-actions.ts | 173 + .../src/store/repos/repo-catalog-fencing.ts | 66 + .../src/store/repos/repo-catalog-identity.ts | 78 + .../src/store/repos/repo-catalog-merge.ts | 141 + src/renderer/src/store/repos/repo-ordering.ts | 86 + src/renderer/src/store/repos/repo-removal.ts | 284 ++ src/renderer/src/store/repos/repo-state.ts | 258 + src/renderer/src/store/repos/repo-update.ts | 216 + .../repos/runtime-repo-catalog-actions.ts | 157 + .../src/store/repos/safe-auto-fork-sync.ts | 54 + .../src/store/runtime-catalog-hosts.ts | 8 + src/renderer/src/store/runtime-target-host.ts | 12 + ...r-workspace-owner-routed-mutations.test.ts | 30 + .../src/store/slices/repos-add-races.test.ts | 81 + .../slices/repos-all-hosts-generation.test.ts | 141 + .../repos-catalog-merge-identity.test.ts | 2 +- .../repos-host-identity-routing.test.ts | 103 + ...repos-module-lifetime-coordinators.test.ts | 108 + ...pos-nested-import-refresh-failures.test.ts | 78 + .../repos-project-groups-delete.test.ts | 52 +- .../repos-project-host-lifecycle.test.ts | 53 + .../store/slices/repos-stale-fetch.test.ts | 26 +- .../slices/repos-update-serialization.test.ts | 79 + src/renderer/src/store/slices/repos.ts | 4182 +---------------- src/renderer/src/store/types.ts | 2 +- 52 files changed, 5503 insertions(+), 4110 deletions(-) create mode 100644 src/renderer/src/store/catalog-identity.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-path-status.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts create mode 100644 src/renderer/src/store/host-catalog-fencing.ts create mode 100644 src/renderer/src/store/project-groups/nested-repository-operations.ts create mode 100644 src/renderer/src/store/project-groups/project-group-catalog-actions.ts create mode 100644 src/renderer/src/store/project-groups/project-group-catalog.ts create mode 100644 src/renderer/src/store/project-groups/project-group-mutations.ts create mode 100644 src/renderer/src/store/project-groups/project-group-owner-stamping.ts create mode 100644 src/renderer/src/store/project-groups/project-group-removal-state.ts create mode 100644 src/renderer/src/store/projects/project-compatibility-core.ts create mode 100644 src/renderer/src/store/projects/project-compatibility-host-merge.ts create mode 100644 src/renderer/src/store/projects/project-host-routing.ts create mode 100644 src/renderer/src/store/projects/project-host-setup-actions.ts create mode 100644 src/renderer/src/store/projects/project-profile-presence.ts create mode 100644 src/renderer/src/store/projects/project-update.ts create mode 100644 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts create mode 100644 src/renderer/src/store/repos/owner-routing.ts create mode 100644 src/renderer/src/store/repos/repo-add-actions.ts create mode 100644 src/renderer/src/store/repos/repo-catalog-actions.ts create mode 100644 src/renderer/src/store/repos/repo-catalog-fencing.ts create mode 100644 src/renderer/src/store/repos/repo-catalog-identity.ts create mode 100644 src/renderer/src/store/repos/repo-catalog-merge.ts create mode 100644 src/renderer/src/store/repos/repo-ordering.ts create mode 100644 src/renderer/src/store/repos/repo-removal.ts create mode 100644 src/renderer/src/store/repos/repo-state.ts create mode 100644 src/renderer/src/store/repos/repo-update.ts create mode 100644 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts create mode 100644 src/renderer/src/store/repos/safe-auto-fork-sync.ts create mode 100644 src/renderer/src/store/runtime-catalog-hosts.ts create mode 100644 src/renderer/src/store/runtime-target-host.ts create mode 100644 src/renderer/src/store/slices/repos-add-races.test.ts create mode 100644 src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts create mode 100644 src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 58554b5fa9f..3278d2ca0f5 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -102,7 +102,6 @@ inline src/renderer/src/store/slices/browser.ts inline src/renderer/src/store/slices/diffComments.ts inline src/renderer/src/store/slices/hosted-review.ts inline src/renderer/src/store/slices/linear.ts -inline src/renderer/src/store/slices/repos.ts inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/web/web-runtime-client.ts diff --git a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts index cf4c6b13ba5..60e5e49776a 100644 --- a/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.ts @@ -11,7 +11,7 @@ import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetr import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' -import type { RepoSlice } from '@/store/slices/repos' +import type { RepoSlice } from '@/store/repos/repo-state' import { createNestedRepoScanId } from './add-repo-dialog-types' import { translate } from '@/i18n/i18n' import { worktreeRefreshOptions } from './add-repo-runtime-owner' diff --git a/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts b/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts index e722f44f347..3db011499d1 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-source-mutation.ts @@ -9,7 +9,7 @@ import { effectiveCustomWorktreeSourceVisibility } from '../../../../shared/worktree/visibility-sources' import { buildWorktreeSourcePreferenceUpdate } from '../../../../shared/worktree/visibility-source-preferences' -import type { RepoUpdate } from '@/store/slices/repos' +import type { RepoUpdate } from '@/store/repos/repo-state' import type { WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' export type WorktreeVisibilitySourceMutation = { diff --git a/src/renderer/src/hooks/composer-state/composer-store-actions.ts b/src/renderer/src/hooks/composer-state/composer-store-actions.ts index e4188bd6e8d..3c0a172dc46 100644 --- a/src/renderer/src/hooks/composer-state/composer-store-actions.ts +++ b/src/renderer/src/hooks/composer-state/composer-store-actions.ts @@ -12,7 +12,7 @@ import type { WorkspaceStatus } from '../../../../shared/worktree/types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { RepoUpdate } from '../../store/slices/repos' +import type { RepoUpdate } from '../../store/repos/repo-state' import type { WorktreeMetaUpdateOptions } from '../../store/slices/worktree-helpers' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { ExecutionHostId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/store/catalog-identity.ts b/src/renderer/src/store/catalog-identity.ts new file mode 100644 index 00000000000..829a3e50bf8 --- /dev/null +++ b/src/renderer/src/store/catalog-identity.ts @@ -0,0 +1,46 @@ +import { structuralValuesEqual } from '../../../shared/structural-value-equality' + +// Why: returning `base` unchanged keeps referential-equality selectors quiet after no-op catalog refreshes. +export function mergeByIdentity( + base: readonly T[], + overlay: readonly T[], + getIdentity: (entry: T) => string +): readonly T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [getIdentity(entry), index])) + let changed = false + for (const entry of overlay) { + const identity = getIdentity(entry) + const index = indexById.get(identity) + if (index === undefined) { + indexById.set(identity, merged.length) + merged.push(entry) + changed = true + continue + } + if (structuralValuesEqual(merged[index], entry)) { + continue + } + merged[index] = entry + changed = true + } + return changed ? merged : base +} + +export function unchangedMergeSource( + previous: readonly T[], + preserved: readonly T[], + merged: readonly T[] +): readonly T[] { + if (merged === preserved && preserved.length === previous.length) { + return previous + } + return merged +} + +export function arrayElementsUnchanged(next: readonly T[], previous: readonly T[]): boolean { + return ( + next === previous || + (next.length === previous.length && next.every((row, index) => row === previous[index])) + ) +} diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts new file mode 100644 index 00000000000..b2f795c36f4 --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts @@ -0,0 +1,83 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { RepoSlice } from '../repos/repo-state' +import { + getFolderWorkspacePathStatusRequestSnapshotForRead, + getFolderWorkspaceStatusRequestSnapshot, + getFreshFolderWorkspacePathStatusFromCache +} from './folder-path-status' +import { + getFolderWorkspacePathStatusRouteSettings, + getFolderWorkspacePathStatusScopeKey, + getRuntimeTargetCachePrefix +} from './folder-workspace-routing' + +export function createFolderPathStatusActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + RepoSlice, + | 'getFolderWorkspacePathStatusCacheKey' + | 'getFreshFolderWorkspacePathStatus' + | 'fetchFolderWorkspacePathStatus' +> { + return { + getFolderWorkspacePathStatusCacheKey: (request, options) => + `${getRuntimeTargetCachePrefix( + getFolderWorkspacePathStatusRouteSettings(options, get().settings) + )}:${getFolderWorkspacePathStatusScopeKey(request)}`, + + getFreshFolderWorkspacePathStatus: (request, options) => { + const state = get() + const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options) + const cached = state.folderWorkspacePathStatuses[cacheKey] + const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request) + return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot }) + }, + + fetchFolderWorkspacePathStatus: async (request, options) => { + const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options) + const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request) + const cached = get().folderWorkspacePathStatuses[cacheKey] + const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({ + entry: cached, + requestSnapshot + }) + if (!options?.force && freshCachedStatus) { + return freshCachedStatus + } + try { + const target = getActiveRuntimeTarget( + getFolderWorkspacePathStatusRouteSettings(options, get().settings) + ) + const status = + target.kind === 'local' + ? await window.api.folderWorkspaces.getPathStatus(request) + : ( + await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>( + target, + 'folderWorkspace.getPathStatus', + request, + { timeoutMs: 15_000 } + ) + ).status + set((state) => ({ + folderWorkspacePathStatuses: + requestSnapshot !== null && + getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot + ? { + ...state.folderWorkspacePathStatuses, + [cacheKey]: { status, checkedAt: Date.now(), requestSnapshot } + } + : state.folderWorkspacePathStatuses + })) + return status + } catch (err) { + console.error('Failed to fetch folder workspace path status:', err) + return null + } + } + } +} diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status.ts b/src/renderer/src/store/folder-workspaces/folder-path-status.ts new file mode 100644 index 00000000000..94592e5328b --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-path-status.ts @@ -0,0 +1,136 @@ +import type { AppState } from '../types' +import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS } from '../../../../shared/folder-workspace-path-status' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusRequest +} from '../../../../shared/folder-workspace-path-status' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import type { FolderWorkspacePathStatusCacheEntry } from '../repos/repo-state' + +export function getFolderWorkspaceStatusRequestSnapshot( + state: Pick, + request: FolderWorkspacePathStatusRequest +): string | null { + if (request.scope === 'path') { + const candidateRepos = state.repos.filter((repo) => + isPathInsideOrEqual(request.path, repo.path) + ) + const relevantConnectionIds = new Set() + if (request.connectionId) { + relevantConnectionIds.add(request.connectionId) + } + for (const repo of candidateRepos) { + if (repo.connectionId) { + relevantConnectionIds.add(repo.connectionId) + } + } + const sshFingerprint = [...relevantConnectionIds] + .map( + (connectionId) => + `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` + ) + .sort() + .join('|') + const repoFingerprint = candidateRepos + .map( + (repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}` + ) + .sort() + .join('|') + return [request.path, '', request.connectionId ?? '', sshFingerprint, repoFingerprint].join( + '\0' + ) + } + + const scope = + request.scope === 'project-group' + ? state.projectGroups.find((group) => group.id === request.projectGroupId) + : state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId) + const projectGroup = + request.scope === 'project-group' + ? scope && 'parentPath' in scope + ? scope + : null + : scope && 'projectGroupId' in scope + ? state.projectGroups.find((group) => group.id === scope.projectGroupId) + : null + const folderPath = + request.scope === 'project-group' + ? scope && 'parentPath' in scope + ? scope.parentPath + : null + : scope && 'folderPath' in scope + ? scope.folderPath + : null + const projectGroupId = + request.scope === 'project-group' + ? request.projectGroupId + : scope && 'projectGroupId' in scope + ? scope.projectGroupId + : null + const scopeConnectionId = + request.scope === 'project-group' + ? scope && 'parentPath' in scope + ? scope.connectionId + : null + : scope && 'folderPath' in scope + ? (scope.connectionId ?? projectGroup?.connectionId) + : null + if (!folderPath || !projectGroupId) { + return null + } + const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId) + const candidateRepos = state.repos.filter( + (repo) => + (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folderPath, repo.path) + ) + const relevantConnectionIds = new Set() + if (scopeConnectionId) { + relevantConnectionIds.add(scopeConnectionId) + } + for (const repo of candidateRepos) { + if (repo.connectionId) { + relevantConnectionIds.add(repo.connectionId) + } + } + const sshFingerprint = [...relevantConnectionIds] + .map( + (connectionId) => + `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` + ) + .sort() + .join('|') + const repoFingerprint = candidateRepos + .map( + (repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}` + ) + .sort() + .join('|') + return [ + folderPath, + projectGroupId, + scopeConnectionId ?? '', + sshFingerprint, + repoFingerprint + ].join('\0') +} + +export function getFreshFolderWorkspacePathStatusFromCache(args: { + entry: FolderWorkspacePathStatusCacheEntry | undefined + requestSnapshot: string | null +}): FolderWorkspacePathStatus | null { + const { entry, requestSnapshot } = args + if (!entry || requestSnapshot === null || entry.requestSnapshot !== requestSnapshot) { + return null + } + return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null +} + +export function getFolderWorkspacePathStatusRequestSnapshotForRead( + state: AppState, + request: FolderWorkspacePathStatusRequest +): string | null { + return getFolderWorkspaceStatusRequestSnapshot(state, request) +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts new file mode 100644 index 00000000000..a5636882ea5 --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts @@ -0,0 +1,142 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import type { FetchedFolderWorkspaceCatalog } from './folder-workspace-catalog' +import type { HostCatalogFence } from '../host-catalog-fencing' +import type { RepoSlice } from '../repos/repo-state' +import { arrayElementsUnchanged } from '../catalog-identity' +import { claimHostCatalogFence, isHostCatalogFenceCurrent } from '../host-catalog-fencing' +import { + clearRestoredFolderWorkspaceSessionOwners, + fetchFolderWorkspaceCatalogForTarget, + getFolderWorkspaceCatalogReplacementIdentities, + mergeFetchedFolderWorkspaceCatalog +} from './folder-workspace-catalog' +import { listRuntimeEnvironmentsForAllHostLoad } from '../runtime-catalog-hosts' +import { getFolderWorkspaceUpdateCoordinator } from './folder-workspace-mutations' + +export function createFolderWorkspaceCatalogActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + fetchFolderWorkspaces: async (options) => { + try { + const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) + const target = getActiveRuntimeTarget( + settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) + ) + const fence = claimHostCatalogFence(get, 'folder-workspaces', target) + const catalog = await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups) + if (!isHostCatalogFenceCurrent(get, fence)) { + return + } + set((current) => { + if (!isHostCatalogFenceCurrent(get, fence)) { + return current + } + folderWorkspaceUpdates.recordCatalogReplacement( + getFolderWorkspaceCatalogReplacementIdentities( + catalog, + current.folderWorkspaces, + current.projectGroups + ) + ) + const { folderWorkspaces } = mergeFetchedFolderWorkspaceCatalog( + catalog, + current.folderWorkspaces, + current.projectGroups + ) + return { + folderWorkspaces, + ...(arrayElementsUnchanged(folderWorkspaces, current.folderWorkspaces) + ? {} + : { folderWorkspacePathStatuses: {} }) + } + }) + } catch (err) { + console.error('Failed to fetch folder workspaces:', err) + } + }, + + fetchFolderWorkspacesForAllHosts: async (options) => { + const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) + // Why: folder workspaces are owned through their project groups; fetch groups first, then merge each host's folder slice. + const applyCatalog = ( + catalog: FetchedFolderWorkspaceCatalog, + fence: HostCatalogFence + ): void => { + if (!isHostCatalogFenceCurrent(get, fence)) { + return + } + set((current) => { + if (!isHostCatalogFenceCurrent(get, fence)) { + return current + } + folderWorkspaceUpdates.recordCatalogReplacement( + getFolderWorkspaceCatalogReplacementIdentities( + catalog, + current.folderWorkspaces, + current.projectGroups + ) + ) + const { folderWorkspaces } = mergeFetchedFolderWorkspaceCatalog( + catalog, + current.folderWorkspaces, + current.projectGroups + ) + return { + folderWorkspaces, + ...(arrayElementsUnchanged(folderWorkspaces, current.folderWorkspaces) + ? {} + : { folderWorkspacePathStatuses: {} }) + } + }) + } + + let failed = false + try { + const target = { kind: 'local' as const } + const fence = claimHostCatalogFence(get, 'folder-workspaces', target) + applyCatalog(await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups), fence) + } catch (err) { + failed = true + console.error('Failed to fetch local folder workspaces for all-host load:', err) + } + if (options?.remoteHosts === 'skip') { + return + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + await Promise.all( + environments.map(async (environment) => { + const target = { + kind: 'environment' as const, + environmentId: environment.id + } + const fence = claimHostCatalogFence(get, 'folder-workspaces', target) + try { + applyCatalog( + await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups), + fence + ) + } catch (err) { + failed = true + console.warn( + `Skipped folder workspaces for runtime environment ${environment.id}:`, + err + ) + } + }) + ) + if (!failed) { + set((s) => ({ + restoredRuntimeHostIdByWorkspaceSessionKey: clearRestoredFolderWorkspaceSessionOwners( + s.restoredRuntimeHostIdByWorkspaceSessionKey, + s + ) + })) + } + } + } +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts new file mode 100644 index 00000000000..5daf9a1ae44 --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts @@ -0,0 +1,236 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' +import type { FolderWorkspaceUpdateTicket } from '../slices/folder-workspace-update-coordinator' +import { callRuntimeRpc, type RuntimeClientTarget } from '../../runtime/runtime-rpc-client' +import type { + FolderWorkspaceUpdateCoordinatorInstance, + FolderWorkspaceUpdateField +} from './folder-workspace-mutations' +import { + folderWorkspaceUpdateInvalidatesPathStatus, + mergeFolderWorkspaceUpdateResponse +} from './folder-workspace-routing' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { mergeByIdentity, unchangedMergeSource } from '../catalog-identity' + +export type FetchedFolderWorkspaceCatalog = { + folderWorkspaces: FolderWorkspace[] + hostId: ExecutionHostId +} + +export function getFolderWorkspaceHostId( + workspace: FolderWorkspace, + projectGroups: readonly ProjectGroup[] +): ExecutionHostId { + const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id + if (explicitHostId) { + return explicitHostId + } + if (workspace.connectionId) { + return toSshExecutionHostId(workspace.connectionId) + } + const matchingHosts = new Set( + projectGroups + .filter((group) => group.id === workspace.projectGroupId) + .map(getProjectGroupHostId) + ) + return matchingHosts.size === 1 + ? ([...matchingHosts][0] as ExecutionHostId) + : LOCAL_EXECUTION_HOST_ID +} + +function getFolderWorkspaceHostIdentity( + workspace: FolderWorkspace, + projectGroups: readonly ProjectGroup[] +): string { + return JSON.stringify([getFolderWorkspaceHostId(workspace, projectGroups), workspace.id]) +} + +export function getFolderWorkspaceUpdateIdentity( + hostId: ExecutionHostId, + folderWorkspaceId: string +): string { + return `${hostId}\0${folderWorkspaceId}` +} + +function mergeFetchedFolderWorkspacesForHost({ + previous, + fetched, + projectGroups, + hostId +}: { + previous: readonly FolderWorkspace[] + fetched: FolderWorkspace[] + projectGroups: readonly ProjectGroup[] + hostId: string +}): readonly FolderWorkspace[] { + const fetchedIdentities = new Set( + fetched.map((workspace) => getFolderWorkspaceHostIdentity(workspace, projectGroups)) + ) + const preserved = previous.filter((workspace) => { + const existingHostId = getFolderWorkspaceHostId(workspace, projectGroups) + return ( + !catalogOwnsHost(hostId, existingHostId) || + fetchedIdentities.has(getFolderWorkspaceHostIdentity(workspace, projectGroups)) + ) + }) + return unchangedMergeSource( + previous, + preserved, + mergeByIdentity(preserved, fetched, (workspace) => + getFolderWorkspaceHostIdentity(workspace, projectGroups) + ) + ) +} + +export function getFolderWorkspaceCatalogReplacementIdentities( + catalog: FetchedFolderWorkspaceCatalog, + currentFolderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): Set { + const replacedIdentities = new Set( + catalog.folderWorkspaces.map((workspace) => + getFolderWorkspaceUpdateIdentity( + getFolderWorkspaceHostId(workspace, projectGroups), + workspace.id + ) + ) + ) + for (const workspace of currentFolderWorkspaces) { + const hostId = getFolderWorkspaceHostId(workspace, projectGroups) + if (catalogOwnsHost(catalog.hostId, hostId)) { + replacedIdentities.add(getFolderWorkspaceUpdateIdentity(hostId, workspace.id)) + } + } + return replacedIdentities +} + +export function clearRestoredFolderWorkspaceSessionOwners( + owners: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] | undefined, + state: Pick +): AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] { + const next: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] = {} + for (const [key, hostId] of Object.entries(owners ?? {})) { + const scope = parseWorkspaceKey(key) + if (scope?.type !== 'folder') { + next[key] = hostId + continue + } + const workspace = state.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId) + if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) { + // Why: ownership resolves via the project group; if that catalog is still missing, keep the restored host owner so a session write doesn't move runtime tabs local. + next[key] = hostId + } + } + return next +} + +export function folderWorkspaceWithFetchedOwner( + workspace: FolderWorkspace, + target: RuntimeClientTarget, + projectGroups: readonly ProjectGroup[] +): FolderWorkspace { + return { + ...workspace, + executionHostId: + target.kind === 'environment' + ? getRuntimeTargetHostId(target) + : getFolderWorkspaceHostId(workspace, projectGroups) + } +} + +export async function fetchFolderWorkspaceCatalogForTarget( + target: RuntimeClientTarget, + projectGroups: readonly ProjectGroup[] +): Promise { + const fetchedFolderWorkspaces = + target.kind === 'local' + ? await window.api.folderWorkspaces.list() + : ( + await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( + target, + 'folderWorkspace.list', + undefined, + { timeoutMs: 15_000, reuseRecentCompatibilityFailure: true } + ) + ).folderWorkspaces + return { + folderWorkspaces: fetchedFolderWorkspaces.map((workspace) => + folderWorkspaceWithFetchedOwner(workspace, target, projectGroups) + ), + hostId: getRuntimeTargetHostId(target) + } +} + +export function mergeFetchedFolderWorkspaceCatalog( + catalog: FetchedFolderWorkspaceCatalog, + currentFolderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): { folderWorkspaces: readonly FolderWorkspace[]; hostId: ExecutionHostId } { + return { + folderWorkspaces: mergeFetchedFolderWorkspacesForHost({ + previous: currentFolderWorkspaces, + fetched: catalog.folderWorkspaces, + projectGroups, + hostId: catalog.hostId + }), + hostId: catalog.hostId + } +} + +export async function reconcileFailedFolderWorkspaceUpdate(args: { + target: RuntimeClientTarget + folderWorkspaceId: string + updateIdentity: string + ownerHostId: ExecutionHostId + ticket: FolderWorkspaceUpdateTicket + coordinator: FolderWorkspaceUpdateCoordinatorInstance + set: Parameters>[0] + get: Parameters>[1] +}): Promise { + try { + const catalog = await fetchFolderWorkspaceCatalogForTarget( + args.target, + args.get().projectGroups + ) + const latestFields = args.coordinator.latestFields(args.updateIdentity, args.ticket) + if (latestFields.length === 0) { + return + } + const refreshed = catalog.folderWorkspaces.find( + (workspace) => workspace.id === args.folderWorkspaceId + ) + args.set((state) => ({ + folderWorkspaces: refreshed + ? state.folderWorkspaces.map((workspace) => + workspace.id === args.folderWorkspaceId && + getFolderWorkspaceHostId(workspace, state.projectGroups) === args.ownerHostId + ? mergeFolderWorkspaceUpdateResponse(workspace, refreshed, latestFields) + : workspace + ) + : state.folderWorkspaces.filter( + (workspace) => + workspace.id !== args.folderWorkspaceId || + getFolderWorkspaceHostId(workspace, state.projectGroups) !== args.ownerHostId + ), + ...(folderWorkspaceUpdateInvalidatesPathStatus(latestFields) || !refreshed + ? { folderWorkspacePathStatuses: {} } + : {}) + })) + if (!refreshed) { + args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)]) + } + } catch (err) { + console.warn('Failed to reconcile folder workspace after update failure:', err) + } +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts new file mode 100644 index 00000000000..6dcc35399c1 --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -0,0 +1,269 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + getActiveRuntimeTarget +} from '../../runtime/runtime-rpc-client' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' +import { + findFolderWorkspaceOwner, + getExecutionHostIdForFolderWorkspace, + getRuntimeEnvironmentIdForFolderWorkspace +} from '@/lib/folder-workspace-runtime-owner' +import { FolderWorkspaceUpdateCoordinator } from '../slices/folder-workspace-update-coordinator' +import type { FolderWorkspaceUpdates, RepoSlice } from '../repos/repo-state' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { + folderWorkspaceUpdateInvalidatesPathStatus, + getFolderWorkspacePathStatusRouteSettings, + mergeFolderWorkspaceUpdateResponse +} from './folder-workspace-routing' +import { + folderWorkspaceWithFetchedOwner, + getFolderWorkspaceHostId, + getFolderWorkspaceUpdateIdentity, + reconcileFailedFolderWorkspaceUpdate +} from './folder-workspace-catalog' + +export type FolderWorkspaceUpdateField = keyof FolderWorkspaceUpdates + +export type FolderWorkspaceUpdateCoordinatorInstance = + FolderWorkspaceUpdateCoordinator + +export type RepoSliceGet = Parameters>[1] + +export const folderWorkspaceUpdateCoordinators = new WeakMap< + RepoSliceGet, + FolderWorkspaceUpdateCoordinatorInstance +>() + +export function getFolderWorkspaceUpdateCoordinator( + get: RepoSliceGet +): FolderWorkspaceUpdateCoordinatorInstance { + const existing = folderWorkspaceUpdateCoordinators.get(get) + if (existing) { + return existing + } + const created = new FolderWorkspaceUpdateCoordinator() + folderWorkspaceUpdateCoordinators.set(get, created) + return created +} + +export function createFolderWorkspaceMutationActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + createFolderWorkspace: async (args, options) => { + try { + // Why: a new folder has no owner yet, so creation follows the caller-selected path-status host. + const target = getActiveRuntimeTarget( + getFolderWorkspacePathStatusRouteSettings(options, get().settings) + ) + if ( + target.kind === 'environment' && + (args.linkedTask?.provider === 'jira' || + args.linkedTaskSourceContext?.provider === 'jira') + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Jira' + ) + } + const workspace = + target.kind === 'local' + ? await window.api.folderWorkspaces.create(args) + : ( + await callRuntimeRpc<{ folderWorkspace: FolderWorkspace }>( + target, + 'folderWorkspace.create', + args, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + const ownedWorkspace = folderWorkspaceWithFetchedOwner( + workspace, + target, + get().projectGroups + ) + set((s) => ({ + folderWorkspaces: [ownedWorkspace, ...s.folderWorkspaces], + folderWorkspacePathStatuses: {} + })) + return ownedWorkspace + } catch (err) { + console.error('Failed to create folder workspace:', err) + const { title, description } = formatFolderWorkspaceCreateError(err) + throw new Error(`${title}. ${description}`) + } + }, + + updateFolderWorkspace: async (folderWorkspaceId, updates, options) => { + const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) + const state = get() + const executionHostId = + options?.executionHostId ?? + (state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) + ? (state.activeWorkspaceExecutionHostId ?? undefined) + : undefined) + if (!findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId)) { + return false + } + const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( + state, + folderWorkspaceId, + executionHostId + ) + // Why: owner-scoped mutations must not follow whichever runtime happens to be focused. + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) + const ownerHostId = executionHostId ?? getRuntimeTargetHostId(target) + const updateIdentity = getFolderWorkspaceUpdateIdentity(ownerHostId, folderWorkspaceId) + // Why: same gate as folderWorkspace.create — an older paired runtime would drop the Jira link silently. + if ( + target.kind === 'environment' && + (updates.linkedTask?.provider === 'jira' || + updates.linkedTaskSourceContext?.provider === 'jira') + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Jira' + ) + } + const updateTicket = folderWorkspaceUpdates.begin( + updateIdentity, + Object.keys(updates) as FolderWorkspaceUpdateField[] + ) + try { + const updated = + target.kind === 'local' + ? await window.api.folderWorkspaces.update({ folderWorkspaceId, updates }) + : ( + await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( + target, + 'folderWorkspace.update', + { folderWorkspaceId, updates }, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + if (!updated) { + await reconcileFailedFolderWorkspaceUpdate({ + target, + folderWorkspaceId, + updateIdentity, + ownerHostId, + ticket: updateTicket, + coordinator: folderWorkspaceUpdates, + set, + get + }) + return false + } + if (updates.diffComments !== undefined && updated.diffComments === undefined) { + // Why: older paired runtimes strip this optional field; reconcile instead of showing an unsaved note. + await reconcileFailedFolderWorkspaceUpdate({ + target, + folderWorkspaceId, + updateIdentity, + ownerHostId, + ticket: updateTicket, + coordinator: folderWorkspaceUpdates, + set, + get + }) + return false + } + const latestFields = folderWorkspaceUpdates.latestFields(updateIdentity, updateTicket) + const catalogChanged = folderWorkspaceUpdates.catalogChanged(updateIdentity, updateTicket) + if (latestFields.length > 0) { + set((s) => ({ + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace.id === folderWorkspaceId && + getFolderWorkspaceHostId(workspace, s.projectGroups) === ownerHostId + ? mergeFolderWorkspaceUpdateResponse(workspace, updated, latestFields, { + rejectOlderResponse: catalogChanged + }) + : workspace + ), + ...(folderWorkspaceUpdateInvalidatesPathStatus(latestFields) + ? { folderWorkspacePathStatuses: {} } + : {}) + })) + } + return true + } catch (err) { + console.error('Failed to update folder workspace:', err) + await reconcileFailedFolderWorkspaceUpdate({ + target, + folderWorkspaceId, + updateIdentity, + ownerHostId, + ticket: updateTicket, + coordinator: folderWorkspaceUpdates, + set, + get + }) + return false + } finally { + folderWorkspaceUpdates.finish(updateIdentity, updateTicket) + } + }, + + deleteFolderWorkspace: async (folderWorkspaceId, options) => { + const state = get() + const executionHostId = options?.executionHostId + if (!findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId)) { + return false + } + const ownerHostId = getExecutionHostIdForFolderWorkspace( + state, + folderWorkspaceId, + executionHostId + ) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( + state, + folderWorkspaceId, + executionHostId + ) + try { + // Why: deletion targets the folder's owner; focus may be on a different host. + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) + const deleted = + target.kind === 'local' + ? await window.api.folderWorkspaces.delete({ folderWorkspaceId }) + : ( + await callRuntimeRpc<{ deleted: boolean }>( + target, + 'folderWorkspace.delete', + { folderWorkspaceId }, + { timeoutMs: 15_000 } + ) + ).deleted + if (!deleted) { + return false + } + const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + set((s) => ({ + folderWorkspaces: s.folderWorkspaces.filter( + (workspace) => + workspace.id !== folderWorkspaceId || + getFolderWorkspaceHostId(workspace, s.projectGroups) !== ownerHostId + ), + folderWorkspacePathStatuses: {} + })) + if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { + get().purgeWorktreeTerminalState([workspaceKey]) + } + return true + } catch (err) { + console.error('Failed to delete folder workspace:', err) + return false + } + } + } +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts new file mode 100644 index 00000000000..eeee9e13dcf --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts @@ -0,0 +1,65 @@ +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import type { FolderWorkspacePathStatusRouteOptions } from '../repos/repo-state' +import type { FolderWorkspaceUpdateField } from './folder-workspace-mutations' + +export function getFolderWorkspacePathStatusScopeKey( + request: FolderWorkspacePathStatusRequest +): string { + if (request.scope === 'project-group') { + return `project-group:${request.projectGroupId}` + } + if (request.scope === 'path') { + return `path:${request.connectionId ?? ''}:${request.path}` + } + return `folder-workspace:${request.folderWorkspaceId}` +} + +export function getRuntimeTargetCachePrefix( + settings: Pick | null | undefined +): string { + const target = getActiveRuntimeTarget(settings) + return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` +} + +export function getFolderWorkspacePathStatusRouteSettings( + options: FolderWorkspacePathStatusRouteOptions | undefined, + fallbackSettings: GlobalSettings | null +): Pick | null | undefined { + return options && 'runtimeEnvironmentId' in options + ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } + : fallbackSettings +} + +export function folderWorkspaceUpdateInvalidatesPathStatus( + fields: readonly FolderWorkspaceUpdateField[] +): boolean { + return fields.includes('folderPath') +} + +export function mergeFolderWorkspaceUpdateResponse( + current: FolderWorkspace, + updated: FolderWorkspace, + fields: readonly FolderWorkspaceUpdateField[], + options: { rejectOlderResponse?: boolean } = {} +): FolderWorkspace { + if ( + fields.length === 0 || + (options.rejectOlderResponse && updated.updatedAt < current.updatedAt) + ) { + return current + } + const next = { ...current } + for (const field of fields) { + // Why: coalesced activity can land an older response after later local bumps. + if (field === 'lastActivityAt') { + next.lastActivityAt = Math.max(current.lastActivityAt, updated.lastActivityAt) + continue + } + Object.assign(next, { [field]: updated[field] }) + } + next.updatedAt = Math.max(current.updatedAt, updated.updatedAt) + return next +} diff --git a/src/renderer/src/store/host-catalog-fencing.ts b/src/renderer/src/store/host-catalog-fencing.ts new file mode 100644 index 00000000000..b94b593cbc2 --- /dev/null +++ b/src/renderer/src/store/host-catalog-fencing.ts @@ -0,0 +1,62 @@ +import type { AppState } from './types' +import type { RuntimeClientTarget } from '../runtime/runtime-rpc-client' +import { isRemovedRuntimeHostId } from './slices/stale-runtime-host-rows' +import { getEnvironmentSshStateGeneration } from './slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from './slices/runtime-status' +import { getRuntimeTargetHostId } from './runtime-target-host' + +export type HostCatalogKind = 'project-groups' | 'folder-workspaces' + +export type HostCatalogFence = { + key: string + generation: number + target: RuntimeClientTarget + sshStateGeneration: number | null + runtimeConnectionGeneration: number | null +} + +const latestHostCatalogGenerationByStore = new WeakMap<() => AppState, Map>() + +export function claimHostCatalogFence( + get: () => AppState, + kind: HostCatalogKind, + target: RuntimeClientTarget +): HostCatalogFence { + const key = `${kind}:${getRuntimeTargetHostId(target)}` + let generations = latestHostCatalogGenerationByStore.get(get) + if (!generations) { + generations = new Map() + latestHostCatalogGenerationByStore.set(get, generations) + } + const generation = (generations.get(key) ?? 0) + 1 + generations.set(key, generation) + return { + key, + generation, + target, + sshStateGeneration: + target.kind === 'environment' ? getEnvironmentSshStateGeneration(target.environmentId) : null, + runtimeConnectionGeneration: + target.kind === 'environment' + ? getRuntimeEnvironmentConnectionGeneration(target.environmentId) + : null + } +} + +export function isHostCatalogFenceCurrent(get: () => AppState, fence: HostCatalogFence): boolean { + if (latestHostCatalogGenerationByStore.get(get)?.get(fence.key) !== fence.generation) { + return false + } + if (fence.target.kind !== 'environment') { + return true + } + return ( + !isRemovedRuntimeHostId( + getRuntimeTargetHostId(fence.target), + get().removedRuntimeEnvironmentIds + ) && + getEnvironmentSshStateGeneration(fence.target.environmentId) === fence.sshStateGeneration && + getRuntimeEnvironmentConnectionGeneration(fence.target.environmentId) === + fence.runtimeConnectionGeneration + ) +} diff --git a/src/renderer/src/store/project-groups/nested-repository-operations.ts b/src/renderer/src/store/project-groups/nested-repository-operations.ts new file mode 100644 index 00000000000..111d359a4bc --- /dev/null +++ b/src/renderer/src/store/project-groups/nested-repository-operations.ts @@ -0,0 +1,130 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import type { AppState } from '../types' +import type { + NestedRepoScanResult, + ProjectGroupImportResult +} from '../../../../shared/project-group-types' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + settingsForRuntimeOwner +} from '../../runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import type { RepoSlice } from '../repos/repo-state' + +export function normalizeNestedRepoScanResult(scan: NestedRepoScanResult): NestedRepoScanResult { + return { + ...scan, + stopped: scan.stopped ?? false, + maxDepth: scan.maxDepth ?? 3, + maxRepos: scan.maxRepos ?? 100, + timeoutMs: scan.timeoutMs ?? null + } +} + +export function createNestedRepositoryActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + scanNestedRepos: async (path, connectionId, controls) => { + try { + const target = getActiveRuntimeTarget( + settingsForRuntimeOwner(get().settings, controls?.runtimeEnvironmentId) + ) + if (target.kind === 'local') { + const unsubscribe = + controls?.scanId && controls.onProgress + ? window.api.projectGroups.onNestedScanProgress(({ scanId, scan }) => { + if (scanId === controls.scanId) { + controls.onProgress?.(normalizeNestedRepoScanResult(scan)) + } + }) + : undefined + try { + return normalizeNestedRepoScanResult( + await window.api.projectGroups.scanNested({ + path, + connectionId, + scanId: controls?.scanId + }) + ) + } finally { + unsubscribe?.() + } + } + return normalizeNestedRepoScanResult( + await callRuntimeRpc( + target, + 'projectGroup.scanNested', + { path }, + // Why: older runtime servers can't stream or cancel scans; keep a bounded failure path for large folders. + { timeoutMs: 20_000 } + ) + ) + } catch (err) { + console.error('Failed to scan nested repos:', err) + return null + } + }, + + cancelNestedRepoScan: async (scanId, options) => { + try { + const target = getActiveRuntimeTarget( + settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) + ) + if (target.kind !== 'local') { + return false + } + return await window.api.projectGroups.cancelNestedScan({ scanId }) + } catch (err) { + console.error('Failed to cancel nested repo scan:', err) + return false + } + }, + + importNestedRepos: async (args) => { + try { + const target = getActiveRuntimeTarget( + settingsForRuntimeOwner(get().settings, args.runtimeEnvironmentId) + ) + const result = + target.kind === 'local' + ? await window.api.projectGroups.importNested(args) + : await callRuntimeRpc( + target, + 'projectGroup.importNested', + { + parentPath: args.parentPath, + groupName: args.groupName, + projectPaths: args.projectPaths, + scanId: args.scanId, + mode: args.mode + }, + { timeoutMs: 60_000 } + ) + const catalogOptions = + 'runtimeEnvironmentId' in args + ? { runtimeEnvironmentId: args.runtimeEnvironmentId } + : undefined + await get().fetchProjectGroups(catalogOptions) + await get().fetchFolderWorkspaces(catalogOptions) + await (args.runtimeEnvironmentId + ? get().fetchRuntimeEnvironmentRepos(args.runtimeEnvironmentId) + : get().fetchRepos(catalogOptions)) + set({ folderWorkspacePathStatuses: {} }) + return result + } catch (err) { + console.error('Failed to import nested repos:', err) + toast.error( + translate('auto.store.slices.repos.6d3318e813', 'Failed to import repositories'), + { + description: err instanceof Error ? err.message : String(err) + } + ) + return null + } + } + } +} diff --git a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts new file mode 100644 index 00000000000..c7841c85e0c --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts @@ -0,0 +1,95 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import type { FetchedProjectGroupCatalog } from './project-group-catalog' +import type { HostCatalogFence } from '../host-catalog-fencing' +import type { RepoSlice } from '../repos/repo-state' +import { arrayElementsUnchanged } from '../catalog-identity' +import { claimHostCatalogFence, isHostCatalogFenceCurrent } from '../host-catalog-fencing' +import { + fetchProjectGroupCatalogForTarget, + mergeFetchedProjectGroupCatalog +} from './project-group-catalog' +import { listRuntimeEnvironmentsForAllHostLoad } from '../runtime-catalog-hosts' + +export function createProjectGroupCatalogActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + fetchProjectGroups: async (options) => { + try { + const target = getActiveRuntimeTarget( + settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) + ) + const fence = claimHostCatalogFence(get, 'project-groups', target) + const catalog = await fetchProjectGroupCatalogForTarget(target) + if (!isHostCatalogFenceCurrent(get, fence)) { + return + } + set((current) => { + if (!isHostCatalogFenceCurrent(get, fence)) { + return current + } + const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, current.projectGroups) + return { + projectGroups, + ...(arrayElementsUnchanged(projectGroups, current.projectGroups) + ? {} + : { folderWorkspacePathStatuses: {} }) + } + }) + } catch (err) { + console.error('Failed to fetch project groups:', err) + } + }, + + fetchProjectGroupsForAllHosts: async (options) => { + // Why: startup renders an all-host sidebar; replacing groups with only the active host leaves other hosts' repos visible but ungrouped. + const applyCatalog = (catalog: FetchedProjectGroupCatalog, fence: HostCatalogFence): void => { + if (!isHostCatalogFenceCurrent(get, fence)) { + return + } + set((s) => { + if (!isHostCatalogFenceCurrent(get, fence)) { + return s + } + const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, s.projectGroups) + return { + projectGroups, + ...(arrayElementsUnchanged(projectGroups, s.projectGroups) + ? {} + : { folderWorkspacePathStatuses: {} }) + } + }) + } + + try { + const target = { kind: 'local' as const } + const fence = claimHostCatalogFence(get, 'project-groups', target) + applyCatalog(await fetchProjectGroupCatalogForTarget(target), fence) + } catch (err) { + console.error('Failed to fetch local project groups for all-host load:', err) + } + if (options?.remoteHosts === 'skip') { + return + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + await Promise.all( + environments.map(async (environment) => { + const target = { + kind: 'environment' as const, + environmentId: environment.id + } + const fence = claimHostCatalogFence(get, 'project-groups', target) + try { + applyCatalog(await fetchProjectGroupCatalogForTarget(target), fence) + } catch (err) { + console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) + } + }) + ) + } + } +} diff --git a/src/renderer/src/store/project-groups/project-group-catalog.ts b/src/renderer/src/store/project-groups/project-group-catalog.ts new file mode 100644 index 00000000000..25a404398e2 --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-catalog.ts @@ -0,0 +1,68 @@ +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { callRuntimeRpc, type RuntimeClientTarget } from '../../runtime/runtime-rpc-client' +import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { projectGroupWithFetchedOwner } from './project-group-owner-stamping' +import { mergeByIdentity, unchangedMergeSource } from '../catalog-identity' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +export type FetchedProjectGroupCatalog = { + projectGroups: ProjectGroup[] + hostId: ExecutionHostId +} + +function getProjectGroupHostIdentity(group: ProjectGroup): string { + return JSON.stringify([getProjectGroupHostId(group), group.id]) +} + +function mergeFetchedProjectGroupsForHost( + previous: readonly ProjectGroup[], + fetched: ProjectGroup[], + hostId: string +): readonly ProjectGroup[] { + const fetchedIdentities = new Set(fetched.map(getProjectGroupHostIdentity)) + const preserved = previous.filter((group) => { + const existingHostId = getProjectGroupHostId(group) + return ( + !catalogOwnsHost(hostId, existingHostId) || + fetchedIdentities.has(getProjectGroupHostIdentity(group)) + ) + }) + return unchangedMergeSource( + previous, + preserved, + mergeByIdentity(preserved, fetched, getProjectGroupHostIdentity) + ) +} + +export async function fetchProjectGroupCatalogForTarget( + target: RuntimeClientTarget +): Promise { + const fetchedGroups = + target.kind === 'local' + ? await window.api.projectGroups.list() + : ( + await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: true + }) + ).groups + return { + projectGroups: fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)), + hostId: getRuntimeTargetHostId(target) + } +} + +export function mergeFetchedProjectGroupCatalog( + catalog: FetchedProjectGroupCatalog, + currentProjectGroups: readonly ProjectGroup[] +): { projectGroups: readonly ProjectGroup[]; hostId: ExecutionHostId } { + return { + projectGroups: mergeFetchedProjectGroupsForHost( + currentProjectGroups, + catalog.projectGroups, + catalog.hostId + ), + hostId: catalog.hostId + } +} diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts new file mode 100644 index 00000000000..e822fd4bfb3 --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -0,0 +1,256 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { selectProjectGroupRemovalTargets } from '../slices/project-group-removal-targets' +import { + catalogOwnsHost, + projectGroupMatchesOwnerHost, + resolveProjectGroupOwnerHostId, + settingsForProjectGroupOwner +} from '../slices/project-group-owner-routing' +import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { ProjectRemovalFailure, RepoSlice } from '../repos/repo-state' +import { mergeProjectCompatibilityForHostRepoChange } from '../repos/repo-catalog-identity' +import { applyProjectGroupDeleteCascade } from './project-group-removal-state' +import { repoWithFetchedOwner, settingsForRepoOwner } from '../repos/owner-routing' +import { projectGroupWithFetchedOwner } from './project-group-owner-stamping' + +export function createProjectGroupMutationActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + RepoSlice, + | 'createProjectGroup' + | 'updateProjectGroup' + | 'deleteProjectGroup' + | 'deleteProjectGroupWithContainedProjects' + | 'moveProjectToGroup' +> { + return { + createProjectGroup: async (name) => { + try { + const target = getActiveRuntimeTarget(get().settings) + const group = + target.kind === 'local' + ? await window.api.projectGroups.create({ + name, + createdFrom: 'manual' + }) + : ( + await callRuntimeRpc<{ group: ProjectGroup }>( + target, + 'projectGroup.create', + { name, createdFrom: 'manual' }, + { timeoutMs: 15_000 } + ) + ).group + const ownedGroup = projectGroupWithFetchedOwner(group, target) + set((s) => ({ + projectGroups: [...s.projectGroups, ownedGroup], + folderWorkspacePathStatuses: {} + })) + return ownedGroup + } catch (err) { + console.error('Failed to create project group:', err) + return null + } + }, + + updateProjectGroup: async (groupId, updates, options) => { + try { + // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) + const target = getActiveRuntimeTarget( + settingsForProjectGroupOwner(get(), groupId, options?.hostId) + ) + const updated = + target.kind === 'local' + ? await window.api.projectGroups.update({ groupId, updates }) + : ( + await callRuntimeRpc<{ group: ProjectGroup | null }>( + target, + 'projectGroup.update', + { groupId, updates }, + { timeoutMs: 15_000 } + ) + ).group + if (!updated) { + return false + } + const ownedGroup = projectGroupWithFetchedOwner(updated, target) + set((s) => ({ + projectGroups: s.projectGroups.map((group) => + projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group + ), + folderWorkspacePathStatuses: {} + })) + return true + } catch (err) { + console.error('Failed to update project group:', err) + return false + } + }, + + deleteProjectGroup: async (groupId, options) => { + try { + // Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere. + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) + const target = getActiveRuntimeTarget( + settingsForProjectGroupOwner(get(), groupId, options?.hostId) + ) + const deleted = + target.kind === 'local' + ? await window.api.projectGroups.delete({ groupId }) + : ( + await callRuntimeRpc<{ deleted: boolean }>( + target, + 'projectGroup.delete', + { groupId }, + { timeoutMs: 15_000 } + ) + ).deleted + if (!deleted) { + return false + } + set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId)) + return true + } catch (err) { + console.error('Failed to delete project group:', err) + return false + } + }, + + deleteProjectGroupWithContainedProjects: async (groupId, options) => { + const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options.hostId) + const targets = selectProjectGroupRemovalTargets( + get().projectGroups, + get().repos, + groupId, + ownerHostId + ) + const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : [] + if (!targets.groupExists) { + return { + status: 'missing-group', + groupId, + requestedProjectIds, + removedProjectIds: [], + failedProjectRemovals: [] + } + } + + const deleted = await get().deleteProjectGroup(groupId, { + hostId: ownerHostId ?? undefined + }) + if (!deleted) { + return { + status: 'group-delete-failed', + groupId, + requestedProjectIds, + removedProjectIds: [], + failedProjectRemovals: [] + } + } + + if (!options.removeContainedProjects) { + return { + status: 'deleted-group', + groupId, + requestedProjectIds, + removedProjectIds: [], + failedProjectRemovals: [] + } + } + + const removedProjectIds: string[] = [] + const failedProjectRemovals: ProjectRemovalFailure[] = [] + // Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows), + // so each project is removed on its own host rather than on the group's. + const findOwnedProjects = (projectId: string): Repo[] => + get().repos.filter( + (repo) => + repo.id === projectId && + (!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo))) + ) + for (const projectId of targets.projectIds) { + const ownedProjects = findOwnedProjects(projectId) + const projectHostId = + ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined + try { + if (ownedProjects.length > 0) { + await get().removeProject(projectId, { hostId: projectHostId }) + } + } catch (err) { + console.error('Failed to remove contained project:', err) + } + const stillExists = findOwnedProjects(projectId).length > 0 + if (stillExists) { + failedProjectRemovals.push({ + projectId, + reason: 'Project remained in Orca after removeProject completed.' + }) + } else { + removedProjectIds.push(projectId) + } + } + + return { + status: 'deleted-group', + groupId, + requestedProjectIds, + removedProjectIds, + failedProjectRemovals + } + }, + + moveProjectToGroup: async (projectId, groupId, order) => { + try { + if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + return false + } + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const moved = + target.kind === 'local' + ? await window.api.projectGroups.moveProject({ + projectId, + groupId, + order + }) + : ( + await callRuntimeRpc<{ repo: Repo | null }>( + target, + 'projectGroup.moveProject', + { repo: projectId, groupId, order }, + { timeoutMs: 15_000 } + ) + ).repo + if (!moved) { + return false + } + const ownedMoved = repoWithFetchedOwner(moved, target) + const movedHostId = getRepoExecutionHostId(ownedMoved) + set((s) => { + const nextRepos = s.repos.map((repo) => + repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo + ) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: movedHostId + }), + folderWorkspacePathStatuses: {} + } + }) + return true + } catch (err) { + console.error('Failed to move repo to group:', err) + return false + } + } + } +} diff --git a/src/renderer/src/store/project-groups/project-group-owner-stamping.ts b/src/renderer/src/store/project-groups/project-group-owner-stamping.ts new file mode 100644 index 00000000000..32ac7888d98 --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-owner-stamping.ts @@ -0,0 +1,17 @@ +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientTarget } from '../../runtime/runtime-rpc-client' +import { getRuntimeTargetHostId } from '../runtime-target-host' + +export function projectGroupWithFetchedOwner( + projectGroup: ProjectGroup, + target: RuntimeClientTarget +): ProjectGroup { + if (target.kind === 'environment') { + return { ...projectGroup, executionHostId: getRuntimeTargetHostId(target) } + } + if (projectGroup.connectionId) { + return { ...projectGroup, executionHostId: toSshExecutionHostId(projectGroup.connectionId) } + } + return { ...projectGroup, executionHostId: LOCAL_EXECUTION_HOST_ID } +} diff --git a/src/renderer/src/store/project-groups/project-group-removal-state.ts b/src/renderer/src/store/project-groups/project-group-removal-state.ts new file mode 100644 index 00000000000..110f9fd4307 --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-removal-state.ts @@ -0,0 +1,39 @@ +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' +import type { RepoSlice } from '../repos/repo-state' +import { getFolderWorkspaceHostId } from '../folder-workspaces/folder-workspace-catalog' + +// Why: a group id can exist on several hosts; only the deleted owner's rows may be cascaded away. +export function applyProjectGroupDeleteCascade( + state: Pick, + groupId: string, + ownerHostId: ExecutionHostId | null +): Pick { + const ownsRowHost = (rowHostId: string): boolean => + ownerHostId ? catalogOwnsHost(ownerHostId, rowHostId) : true + const ownerGroups = state.projectGroups.filter((group) => + ownsRowHost(getProjectGroupHostId(group)) + ) + const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) + const isDeletedGroup = (group: ProjectGroup): boolean => + deletedGroupIds.has(group.id) && ownsRowHost(getProjectGroupHostId(group)) + return { + projectGroups: state.projectGroups.filter((group) => !isDeletedGroup(group)), + folderWorkspaces: state.folderWorkspaces.filter( + (workspace) => + !deletedGroupIds.has(workspace.projectGroupId) || + // Why: resolve the workspace's host against the pre-delete group list, which still holds its owner row. + !ownsRowHost(getFolderWorkspaceHostId(workspace, state.projectGroups)) + ), + repos: state.repos.map((repo) => + repo.projectGroupId && + deletedGroupIds.has(repo.projectGroupId) && + ownsRowHost(getRepoExecutionHostId(repo)) + ? { ...repo, projectGroupId: null } + : repo + ), + folderWorkspacePathStatuses: {} + } +} diff --git a/src/renderer/src/store/projects/project-compatibility-core.ts b/src/renderer/src/store/projects/project-compatibility-core.ts new file mode 100644 index 00000000000..c0882c8e683 --- /dev/null +++ b/src/renderer/src/store/projects/project-compatibility-core.ts @@ -0,0 +1,242 @@ +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { + mergeCatalogCreatedAt, + mergeCatalogUpdatedAt, + projectHostSetupProjectionFromRepos +} from '../../../../shared/project-host-setup-projection' +import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { ProjectUpdate, RepoSlice } from '../repos/repo-state' + +export function projectCompatibilityFromRepos( + repos: readonly Repo[] +): Pick { + const projection = projectHostSetupProjectionFromRepos(repos) + return { + projects: projection.projects, + projectHostSetups: projection.setups + } +} + +export function mergeProjectCompatibilityProject(base: Project, overlay: Project): Project { + const localWindowsRuntimePreference = + 'localWindowsRuntimePreference' in overlay + ? overlay.localWindowsRuntimePreference + : base.localWindowsRuntimePreference + const project: Project = { + ...base, + ...overlay, + // Why: all-host startup fetches hosts separately; one host's record must not erase repo ownership learned from another host with the same id. + sourceRepoIds: [...new Set([...base.sourceRepoIds, ...overlay.sourceRepoIds])], + // Why not plain min/max: a host whose repos carry no `addedAt` projects 0, and 0 means + // unknown, not epoch — it must not win over the real timestamp the other host knows. + createdAt: mergeCatalogCreatedAt(base.createdAt, overlay.createdAt), + updatedAt: mergeCatalogUpdatedAt(base.updatedAt, overlay.updatedAt) + } + if (localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = localWindowsRuntimePreference + } + return project +} + +export function mergeProjectCompatibilityProjects( + base: readonly Project[], + overlay: readonly Project[] +): Project[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = mergeProjectCompatibilityProject(merged[index]!, entry) + } + } + return merged +} + +export function mergeUpdatedProjectCompatibilityProject( + base: Project, + updated: Project, + updates: ProjectUpdate +): Project { + const project = mergeProjectCompatibilityProject(base, updated) + if ('localWindowsRuntimePreference' in updates) { + const localWindowsRuntimePreference = + 'localWindowsRuntimePreference' in updated + ? updated.localWindowsRuntimePreference + : updates.localWindowsRuntimePreference + // Why: project.update returns one host's record, but preference clears must override the cross-host metadata-preservation merge. + if (localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = localWindowsRuntimePreference + } + } + return project +} + +export function getCurrentSourceRepoIds( + project: Project, + currentRepoIds: ReadonlySet +): string[] { + return project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) +} + +export function getReposById(repos: readonly Repo[]): Map { + const reposById = new Map() + for (const repo of repos) { + const existing = reposById.get(repo.id) + if (existing) { + existing.push(repo) + } else { + reposById.set(repo.id, [repo]) + } + } + return reposById +} + +export function getSourceRepoIdsOutsideHost( + project: Project, + reposById: ReadonlyMap, + hostId: string +): string[] { + return project.sourceRepoIds.filter((repoId) => { + const repos = reposById.get(repoId) ?? [] + return repos.some((repo) => getRepoExecutionHostId(repo) !== hostId) + }) +} + +export function getMergedSourceRepoIdsForHostRefresh( + previous: Project, + current: Project, + reposById: ReadonlyMap, + hostId: string +): string[] { + // Why: current-first keeps the order independent of which host refreshed. Prefixing the + // cross-host remainder made it a function of hostId, so a cross-host project's ids oscillated + // between refreshes and the projects reconcile could never reuse the row. + return [ + ...new Set([ + ...getCurrentSourceRepoIds(current, new Set(reposById.keys())), + ...getSourceRepoIdsOutsideHost(previous, reposById, hostId) + ]) + ] +} + +export function projectWithCurrentSourceRepoIds( + project: Project, + currentRepoIds: ReadonlySet +): Project { + const sourceRepoIds = getCurrentSourceRepoIds(project, currentRepoIds) + return sourceRepoIds.length === project.sourceRepoIds.length + ? project + : { ...project, sourceRepoIds } +} + +export function getLocalHostRepoBadgeColor( + project: Project, + reposById: ReadonlyMap +): string | null { + for (const repoId of project.sourceRepoIds) { + for (const repo of reposById.get(repoId) ?? []) { + if (getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) { + return repo.badgeColor + } + } + } + return null +} + +export function mergePreviousProjectMetadata( + previous: Project, + current: Project, + reposById: ReadonlyMap, + hostId: string +): Project { + const project = mergeProjectCompatibilityProject(previous, current) + const sourceRepoIds = getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + const localBadgeColor = getLocalHostRepoBadgeColor({ ...project, sourceRepoIds }, reposById) + if (localBadgeColor !== null) { + // Why: badge color is per-host repo metadata; a remote host sharing the project must not repaint the color the user chose locally. + project.badgeColor = localBadgeColor + } + if (hostId === LOCAL_EXECUTION_HOST_ID) { + // Why: localWindowsRuntimePreference belongs to the local host; a local refresh that omits it is authoritative and clears stale renderer state. + if ('localWindowsRuntimePreference' in current) { + if (current.localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = current.localWindowsRuntimePreference + } + } else { + delete project.localWindowsRuntimePreference + } + } else if (previous.localWindowsRuntimePreference !== undefined) { + // Why: a remote runtime's local Windows preference must not overwrite the client-local project runtime setting. + project.localWindowsRuntimePreference = previous.localWindowsRuntimePreference + } + return { + ...project, + // Why: fetched project metadata can lag repo.list; track ownership to the reconciled repos so removed-host repos don't linger. + sourceRepoIds + } +} + +export function mergeProjectHostSetupCompatibility( + derived: Pick, + fetched: ProjectHostSetupProjection +): Pick { + const fetchedRepoSetupKeys = new Set(fetched.setups.map(getRepoDerivedSetupKey)) + const derivedSetups = derived.projectHostSetups.filter( + (setup) => !fetchedRepoSetupKeys.has(getRepoDerivedSetupKey(setup)) + ) + const projectHostSetups = mergeProjectHostSetupsByOwner(derivedSetups, fetched.setups) + const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) + const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) + return { + projects: mergeProjectCompatibilityProjects(derived.projects, fetched.projects).filter( + (project) => fetchedProjectIds.has(project.id) || setupProjectIds.has(project.id) + ), + projectHostSetups + } +} + +export function getRepoDerivedSetupKey(setup: ProjectHostSetup): string { + // Why: authoritative routing provenance may be absent from the repo-derived fallback it replaces. + return JSON.stringify([setup.hostId, setup.repoId || setup.id]) +} + +export function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { + return JSON.stringify([ + setup.hostId, + setup.executionHostId ?? setup.hostId, + setup.runtimeOwnerEnvironmentId ?? null, + setup.repoId || setup.id + ]) +} + +export function mergeProjectHostSetupsByOwner( + base: readonly ProjectHostSetup[], + overlay: readonly ProjectHostSetup[] +): ProjectHostSetup[] { + const merged = [...base] + const indexByOwner = new Map( + merged.map((entry, index) => [getProjectHostSetupOwnerKey(entry), index]) + ) + for (const entry of overlay) { + const index = indexByOwner.get(getProjectHostSetupOwnerKey(entry)) + if (index === undefined) { + indexByOwner.set(getProjectHostSetupOwnerKey(entry), merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged +} diff --git a/src/renderer/src/store/projects/project-compatibility-host-merge.ts b/src/renderer/src/store/projects/project-compatibility-host-merge.ts new file mode 100644 index 00000000000..8c51cbe9a4b --- /dev/null +++ b/src/renderer/src/store/projects/project-compatibility-host-merge.ts @@ -0,0 +1,226 @@ +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId +} from '../../../../shared/execution-host' +import type { RepoSlice } from '../repos/repo-state' +import { + getProjectHostSetupOwnerKey, + getReposById, + getSourceRepoIdsOutsideHost, + mergePreviousProjectMetadata, + mergeProjectCompatibilityProjects, + mergeProjectHostSetupsByOwner, + projectWithCurrentSourceRepoIds +} from './project-compatibility-core' + +export function getProjectHostIds( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] +): Set { + const hostIds = getExplicitProjectHostIds(project, setups, repos) + if (hostIds.size === 0) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } + return hostIds +} + +export function getExplicitProjectHostIds( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] +): Set { + const hostIds = new Set() + const sourceRepoIds = new Set(project.sourceRepoIds) + for (const setup of setups) { + if (setup.projectId === project.id) { + hostIds.add(setup.hostId) + } + } + for (const repo of repos) { + if (sourceRepoIds.has(repo.id)) { + hostIds.add(getRepoExecutionHostId(repo)) + } + } + return hostIds +} + +export function indexProjectHostSetupsByProjectId( + setups: readonly ProjectHostSetup[] +): Map { + const setupsByProjectId = new Map() + for (const setup of setups) { + const existing = setupsByProjectId.get(setup.projectId) + if (existing) { + existing.push(setup) + } else { + setupsByProjectId.set(setup.projectId, [setup]) + } + } + return setupsByProjectId +} + +export function getProjectSourceRepos( + project: Project, + reposById: ReadonlyMap +): Repo[] { + const sourceRepos: Repo[] = [] + for (const repoId of project.sourceRepoIds) { + for (const repo of reposById.get(repoId) ?? []) { + sourceRepos.push(repo) + } + } + return sourceRepos +} + +// Why: the host-id resolvers rescan every setup and repo per project; feeding them the project's own slices keeps a catalog refresh linear. +export function createProjectHostIdIndex( + setups: readonly ProjectHostSetup[], + reposById: ReadonlyMap, + resolveHostIds: ( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] + ) => Set +): (project: Project) => ReadonlySet { + const noSetups: readonly ProjectHostSetup[] = [] + const hostIdsByProject = new Map>() + let setupsByProjectId: Map | null = null + return (project) => { + const cached = hostIdsByProject.get(project) + if (cached) { + return cached + } + setupsByProjectId ??= indexProjectHostSetupsByProjectId(setups) + const hostIds = resolveHostIds( + project, + setupsByProjectId.get(project.id) ?? noSetups, + getProjectSourceRepos(project, reposById) + ) + hostIdsByProject.set(project, hostIds) + return hostIds + } +} + +// Why: mergePreviousProjectMetadata scans the whole catalog's repo key set; a view holding only this pair's repos keeps that scan per-project. +export function restrictReposToProjectPair( + previous: Project, + current: Project, + reposById: ReadonlyMap +): Map { + const restricted = new Map() + for (const project of [previous, current]) { + for (const repoId of project.sourceRepoIds) { + const matches = reposById.get(repoId) + if (matches) { + restricted.set(repoId, matches) + } + } + } + return restricted +} + +export function mergeFetchedProjectCompatibilityForHost({ + previous, + fetched, + repos, + hostId +}: { + previous: Pick + fetched: Pick + repos: readonly Repo[] + hostId: string +}): Pick { + const setupBelongsToFetchedCatalog = (setup: ProjectHostSetup): boolean => { + if (hostId !== LOCAL_EXECUTION_HOST_ID) { + return setup.hostId === hostId + } + const owner = parseExecutionHostId(setup.hostId) + // Why: desktop persistence owns local and direct-SSH setups; runtime setups stay authoritative on their remote Orca server. + return setup.hostId === LOCAL_EXECUTION_HOST_ID || owner?.kind === 'ssh' + } + const fetchedSetupsForHost = fetched.projectHostSetups.filter(setupBelongsToFetchedCatalog) + const preservedSetups = previous.projectHostSetups.filter( + (setup) => !setupBelongsToFetchedCatalog(setup) + ) + const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) + const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) + const reposById = getReposById(repos) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const fetchedProjectHostIds = createProjectHostIdIndex( + fetched.projectHostSetups, + reposById, + getProjectHostIds + ) + const previousProjectHostIds = createProjectHostIdIndex( + previous.projectHostSetups, + reposById, + getProjectHostIds + ) + const currentProjectOwnerHostIds = createProjectHostIdIndex( + projectHostSetups, + reposById, + getExplicitProjectHostIds + ) + const projectHasCurrentOwnerOutsideHost = (project: Project): boolean => { + for (const ownerHostId of currentProjectOwnerHostIds(project)) { + if (ownerHostId !== hostId) { + return true + } + } + return false + } + const fetchedProjects = fetched.projects + .filter((project) => { + const previousProject = previousProjectById.get(project.id) + // Why: repo-derived compatibility projects include every host; a one-host refresh should only reconcile or prune that host's ownership. + return ( + fetchedProjectHostIds(project).has(hostId) || + (previousProject ? previousProjectHostIds(previousProject).has(hostId) : false) + ) + }) + .map((project) => { + const previousProject = previousProjectById.get(project.id) + return previousProject + ? mergePreviousProjectMetadata( + previousProject, + project, + restrictReposToProjectPair(previousProject, project, reposById), + hostId + ) + : projectWithCurrentSourceRepoIds(project, currentRepoIds) + }) + const fetchedProjectIds = new Set(fetchedProjects.map((project) => project.id)) + const preservedProjects = previous.projects.filter( + (project) => + !fetchedProjectIds.has(project.id) && + (!previousProjectHostIds(project).has(hostId) || projectHasCurrentOwnerOutsideHost(project)) + ) + // Why: both merges always allocate (sourceRepoIds is rebuilt per project, and fetched setups + // arrive freshly cloned over IPC), so reconcile against `previous` to recover identity when a + // refresh changed nothing. Each key is what the producing merge already dedups by. + return { + projects: reconcileCatalogRows( + previous.projects, + mergeProjectCompatibilityProjects( + preservedProjects.map((project) => { + const sourceRepoIds = getSourceRepoIdsOutsideHost(project, reposById, hostId) + return sourceRepoIds.length === project.sourceRepoIds.length + ? project + : { ...project, sourceRepoIds } + }), + fetchedProjects + ), + (project) => project.id + ), + projectHostSetups: reconcileCatalogRows( + previous.projectHostSetups, + projectHostSetups, + getProjectHostSetupOwnerKey + ) + } +} diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts new file mode 100644 index 00000000000..43e32524ad3 --- /dev/null +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -0,0 +1,135 @@ +import type { AppState } from '../types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupExistingFolderArgs +} from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from '../../../../shared/project-host-setup-projection' +import { + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + getActiveRuntimeTarget, + type RuntimeClientTarget +} from '../../runtime/runtime-rpc-client' +import { getRuntimeTargetHostId } from '../runtime-target-host' + +export function getProjectSetupRuntimeTarget( + hostId: ProjectHostSetupExistingFolderArgs['hostId'] +): RuntimeClientTarget { + const parsedHost = parseExecutionHostId(hostId) + return parsedHost?.kind === 'runtime' + ? { kind: 'environment', environmentId: parsedHost.environmentId } + : { kind: 'local' } +} + +export function getProjectUpdateRuntimeTarget( + state: AppState, + projectId: string +): RuntimeClientTarget { + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return target + } + const runtimeHostId = getRuntimeTargetHostId(target) + return state.projectHostSetups.some( + (setup) => setup.projectId === projectId && setup.hostId === runtimeHostId + ) + ? target + : { kind: 'local' } +} + +export function setupWithFetchedOwner( + setup: ProjectHostSetup, + target: RuntimeClientTarget +): ProjectHostSetup { + const hostId = getRuntimeTargetHostId(target) + if (target.kind !== 'environment') { + return setup + } + const executionHostId = setup.executionHostId ?? setup.hostId + return { + ...setup, + hostId, + executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, + runtimeOwnerEnvironmentId: target.environmentId, + // Why: paired clients route through the HUB and must not treat its private SSH target as client-local configuration. + connectionId: null + } +} + +async function assertProjectHostSetupRuntimeCapability(target: RuntimeClientTarget): Promise { + if (target.kind !== 'environment') { + return + } + await assertRuntimeEnvironmentCapability( + target.environmentId, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + 'The selected Orca server does not support project host setup yet. Update Orca on the server and try again.', + 15_000 + ) +} + +export async function fetchProjectHostSetupCompatibility( + target: RuntimeClientTarget, + repos: readonly Repo[] +): Promise { + try { + if (target.kind === 'local') { + const projectsApi = ( + window.api as typeof window.api & { + projects?: { + list?: () => Promise + listHostSetups?: () => Promise + } + } + ).projects + if (!projectsApi?.list || !projectsApi.listHostSetups) { + throw new Error('projects_api_unavailable') + } + return { + projects: await projectsApi.list(), + setups: await projectsApi.listHostSetups() + } + } + await assertProjectHostSetupRuntimeCapability(target) + const [projectResponse, setupResponse] = await Promise.all([ + callRuntimeRpc<{ projects: Project[] }>(target, 'project.list', undefined, { + timeoutMs: 15_000 + }), + callRuntimeRpc<{ setups: ProjectHostSetup[] }>(target, 'projectHostSetup.list', undefined, { + timeoutMs: 15_000 + }) + ]) + return { + projects: projectResponse.projects, + setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) + } + } catch { + // Why: newer clients must hydrate against older runtimes that only know repo.list; derive the transitional model locally. + return projectHostSetupProjectionFromRepos(repos) + } +} + +export async function assertProjectHostSetupMutationRuntimeCapabilities( + target: RuntimeClientTarget +): Promise { + if (target.kind !== 'environment') { + return + } + await assertProjectHostSetupRuntimeCapability(target) + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, + 'The selected Orca server does not support explicit workspace run hosts yet. Update Orca on the server and try again.', + 15_000 + ) +} diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts new file mode 100644 index 00000000000..97e74dbc9b8 --- /dev/null +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -0,0 +1,284 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import type { AppState } from '../types' +import type { + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteResult, + ProjectHostSetupResult, + ProjectHostSetupUpdateResult +} from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { omitSparsePresetsForRepos } from '../slices/sparse-presets' +import { repoMatchesHostIdentity } from '../slices/repo-host-identity' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { RepoSlice } from '../repos/repo-state' +import { ERROR_TOAST_DURATION } from '../repos/repo-state' +import { repoWithFetchedOwner } from '../repos/owner-routing' +import { + assertProjectHostSetupMutationRuntimeCapabilities, + getProjectSetupRuntimeTarget, + setupWithFetchedOwner +} from './project-host-routing' + +export function createProjectHostSetupActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick< + RepoSlice, + | 'setupProjectExistingFolder' + | 'createProjectHostSetup' + | 'updateProjectHostSetup' + | 'deleteProjectHostSetup' + | 'setupProjectClone' +> { + return { + setupProjectExistingFolder: async (args) => { + try { + const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const projectProviderIdentity = + args.projectProviderIdentity ?? + get().projects.find((project) => project.id === args.projectId)?.providerIdentity + // Why: the target host may not have a project record yet; carry the selected source-host identity across the boundary. + const setupArgs = projectProviderIdentity ? { ...args, projectProviderIdentity } : args + const result = + target.kind === 'local' + ? await window.api.projects.setupExistingFolder(setupArgs) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupResult }>( + target, + 'projectHostSetup.setupExistingFolder', + setupArgs, + { timeoutMs: 15_000 } + ) + ).result + const repo = repoWithFetchedOwner(result.repo, target) + const repoHostId = getRepoExecutionHostId(repo) + const setup = setupWithFetchedOwner(result.setup, target) + set((s) => { + const nextRepos = s.repos.some((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) + ) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry + ) + : [...s.repos, repo] + const nextProjects = s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project] + const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + return { + repos: nextRepos, + projects: nextProjects, + projectHostSetups: nextSetups + } + }) + toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { + description: repo.displayName + }) + return { ...result, repo, setup } + } catch (err) { + console.error('Failed to set up project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + createProjectHostSetup: async (args) => { + try { + const target = getProjectSetupRuntimeTarget(args.hostId) + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.createHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupCreateResult }>( + target, + 'projectHostSetup.create', + args, + { timeoutMs: 15_000 } + ) + ).result + const setup = setupWithFetchedOwner(result.setup, target) + set((s) => ({ + projects: s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project], + projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + })) + return { project: result.project, setup } + } catch (err) { + console.error('Failed to create project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + updateProjectHostSetup: async (args) => { + try { + const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) + const target = currentSetup + ? getProjectSetupRuntimeTarget(currentSetup.hostId) + : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.updateHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupUpdateResult }>( + target, + 'projectHostSetup.update', + args, + { timeoutMs: 15_000 } + ) + ).result + const setup = setupWithFetchedOwner(result.setup, target) + const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null + set((s) => ({ + repos: repo + ? s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId!)) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId!) ? repo : entry + ) + : [...s.repos, repo] + : s.repos, + projects: s.projects.some((entry) => entry.id === result.project.id) + ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) + : [...s.projects, result.project], + projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) + ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.projectHostSetups, setup] + })) + return { ...result, repo, setup } + } catch (err) { + console.error('Failed to update project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + }, + + deleteProjectHostSetup: async (args) => { + try { + const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) + const target = currentSetup + ? getProjectSetupRuntimeTarget(currentSetup.hostId) + : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + const result = + target.kind === 'local' + ? await window.api.projects.deleteHostSetup(args) + : ( + await callRuntimeRpc<{ result: ProjectHostSetupDeleteResult }>( + target, + 'projectHostSetup.delete', + args, + { timeoutMs: 15_000 } + ) + ).result + const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null + set((s) => { + const projectHostSetups = s.projectHostSetups.filter( + (setup) => setup.id !== result.setup.id + ) + const repos = + repo && repoHostId + ? s.repos.filter((entry) => !repoMatchesHostIdentity(entry, repo.id, repoHostId)) + : s.repos + const projects = + repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) + ? s.projects.filter((project) => project.id !== result.project.id) + : s.projects + const survivingRepoIds = new Set(repos.map((r) => r.id)) + const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) + return { + repos, + projects, + projectHostSetups, + ...omitSparsePresetsForRepos(s, removedRepoIds) + } + }) + return { ...result, repo } + } catch (err) { + console.error('Failed to delete project host setup:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error( + translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), + { + description: message, + duration: ERROR_TOAST_DURATION + } + ) + return null + } + }, + + setupProjectClone: async (args) => { + try { + const parsedHost = parseExecutionHostId(args.hostId) + const target = getProjectSetupRuntimeTarget(args.hostId) + if (parsedHost?.kind !== 'ssh') { + await assertProjectHostSetupMutationRuntimeCapabilities(target) + } + const repo = + parsedHost?.kind === 'ssh' + ? await window.api.repos.cloneRemote({ + connectionId: parsedHost.targetId, + url: args.url, + destination: args.destination + }) + : target.kind === 'local' + ? await window.api.repos.clone({ + url: args.url, + destination: args.destination + }) + : ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.clone', + { + url: args.url, + destination: args.destination + }, + { timeoutMs: 10 * 60_000 } + ) + ).repo + return await get().setupProjectExistingFolder({ + projectId: args.projectId, + hostId: args.hostId, + path: repo.path, + kind: 'git', + displayName: args.displayName, + setupMethod: 'cloned' + }) + } catch (err) { + console.error('Failed to clone project on host:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + } + } +} diff --git a/src/renderer/src/store/projects/project-profile-presence.ts b/src/renderer/src/store/projects/project-profile-presence.ts new file mode 100644 index 00000000000..10bbaa560ea --- /dev/null +++ b/src/renderer/src/store/projects/project-profile-presence.ts @@ -0,0 +1,48 @@ +import { toast } from 'sonner' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { translate } from '@/i18n/i18n' + +export function formatProjectPresenceProfileNames(profileNames: readonly string[]): string { + const names = [...new Set(profileNames.map((name) => name.trim()).filter(Boolean))] + if (names.length <= 3) { + return names.join(', ') + } + // Why: the "+N more" overflow suffix is user-visible toast copy and must localize. + return translate('auto.store.slices.repos.presenceProfileOverflow', '{{names}} +{{count}} more', { + names: names.slice(0, 3).join(', '), + count: names.length - 3 + }) +} + +export async function warnIfProjectKnownInAnotherProfile( + repo: Repo, + activeOrcaProfileId: string | null +): Promise { + const findProjectProfiles = window.api.orcaProfiles?.findProjectProfiles + // Why: without an active profile ID the scan can't exclude the current profile and would false-positive on the just-added project. + if (!findProjectProfiles || !activeOrcaProfileId) { + return + } + try { + const result = await findProjectProfiles({ + path: repo.path, + connectionId: repo.connectionId ?? null, + executionHostId: getRepoExecutionHostId(repo), + excludeProfileId: activeOrcaProfileId + }) + const description = formatProjectPresenceProfileNames( + result.projects.map((project) => project.profileName) + ) + if (!description) { + return + } + toast.warning( + translate('auto.store.slices.repos.2dcd706774', 'Project also exists in another profile'), + { description } + ) + } catch (err) { + // Why: adding a project should not fail because an advisory profile scan failed. + console.warn('Failed to check project presence in other profiles:', err) + } +} diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts new file mode 100644 index 00000000000..7b3ece6fd62 --- /dev/null +++ b/src/renderer/src/store/projects/project-update.ts @@ -0,0 +1,52 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Project } from '../../../../shared/project-types' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' +import type { RepoSlice } from '../repos/repo-state' +import { getProjectUpdateRuntimeTarget } from './project-host-routing' +import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' + +export function createProjectUpdateActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + updateProject: async (projectId, updates) => { + try { + const target = getProjectUpdateRuntimeTarget(get(), projectId) + const updatedProject = + target.kind === 'local' + ? await window.api.projects.update({ projectId, updates }) + : ( + await callRuntimeRpc<{ project: Project }>( + target, + 'project.update', + { projectId, updates }, + { timeoutMs: 15_000 } + ) + ).project + if (!updatedProject) { + return false + } + const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates + set((state) => ({ + projects: state.projects.map((project) => + project.id === projectId + ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) + : project + ), + folderWorkspacePathStatuses: {} + })) + if (runtimePreferenceChanged) { + get().clearLocalDetectedAgents() + notifyInstalledAgentSkillsChanged() + } + return true + } catch (err) { + console.error('Failed to update project:', err) + return false + } + } + } +} diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts new file mode 100644 index 00000000000..34cb6c0a965 --- /dev/null +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -0,0 +1,199 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' +import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' +import { readRuntimeWorktreeVisibilitySnapshot } from '../slices/worktree-visibility-owner-settings' +import { getRepoHostIdentity } from '../slices/repo-host-identity' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' +import type { FetchedRepoCatalog } from './repo-catalog-merge' +import type { LocalRepoCatalogFetchOutcome } from './repo-catalog-fencing' +import type { RepoSlice } from './repo-state' +import { arrayElementsUnchanged } from '../catalog-identity' +import { + claimRepoCatalogGeneration, + isLatestRepoCatalogGeneration, + latestAllHostRepoCatalogGenerationByStore, + startLocalRepoCatalogFetch +} from './repo-catalog-fencing' +import { + fetchRepoCatalogForTarget, + filterSetupsForPrunedRepoRows, + filterTrustedOrcaHooksToValidRepos, + mergeFetchedRepoCatalog, + projectCompatibilityForReconciledRepos, + reconcileReadoptedSshWorktreeState, + reconcileSupersededSshRepos +} from './repo-catalog-merge' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { listRuntimeEnvironmentsForAllHostLoad } from '../runtime-catalog-hosts' +import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' + +export function createAllHostRepoCatalogActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + fetchReposForAllHosts: async (options) => { + const settleLocalCatalog = startLocalRepoCatalogFetch(get) + let generation = 0 + set((s) => { + generation = s.reposFetchGeneration + 1 + return { reposFetchGeneration: generation } + }) + latestAllHostRepoCatalogGenerationByStore.set(get, generation) + claimRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) + // Why: fetching only the active host hides every other host's repos ("my projects vanished"); load local + all runtime envs, each failing soft. + const applyCatalog = (catalog: FetchedRepoCatalog): void => { + // Why: a concurrent all-host refresh must not let the older catalog resurrect a migrated SSH owner. + if ( + latestAllHostRepoCatalogGenerationByStore.get(get) !== generation || + !isLatestRepoCatalogGeneration(get, catalog.hostId, generation) + ) { + return + } + let hostRepos: Repo[] = [] + set((s) => { + // Why: skip a catalog whose env was tombstoned mid-load (removed), not one merely absent from the not-yet-hydrated saved list (#8881). + if (isRemovedRuntimeHostId(catalog.hostId, s.removedRuntimeEnvironmentIds)) { + return s + } + const result = mergeFetchedRepoCatalog(catalog, s.repos) + const reconciliation = reconcileSupersededSshRepos(result.repos, s) + const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) + const projectCompatibility = projectCompatibilityForReconciledRepos( + finalizedRepos, + catalog.projectHostSetupCompatibility + ) + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: filterSetupsForPrunedRepoRows( + s.projectHostSetups, + result.repos, + finalizedRepos + ) + }, + fetched: projectCompatibility, + repos: finalizedRepos, + hostId: result.hostId + }) + hostRepos = finalizedRepos.filter( + (repo) => getRepoExecutionHostId(repo) === result.hostId + ) + return { + repos: finalizedRepos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), + ...mergedProjectCompatibility, + ...(arrayElementsUnchanged(finalizedRepos, s.repos) + ? {} + : { folderWorkspacePathStatuses: {} }), + activeRepoId: s.activeRepoId, + filterRepoIds: s.filterRepoIds, + setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds + } + }) + // Why: keep the safe-auto fork sync (as fetchRepos does) so cold-start, which now routes here, still updates safe-auto forks. + scheduleSafeAutoForkSync(get, hostRepos) + } + const validateRepoScopedUi = (): void => { + set((s) => { + const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) + return { + activeRepoId: + s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds) + } + }) + } + + // Local first so local repos are present even if a remote fetch stalls. + let failed = false + let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } + try { + applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' })) + } catch (err) { + failed = true + localCatalogOutcome = { status: 'rejected', reason: err } + console.error('Failed to fetch local repos for all-host load:', err) + } + // Why: startup hydration needs the newest local catalog, not unreachable remote hosts. + settleLocalCatalog(localCatalogOutcome) + // Why: a newer local-only refresh must not cancel this load's unrelated remote catalogs. + if (latestAllHostRepoCatalogGenerationByStore.get(get) !== generation) { + return + } + if (options?.remoteHosts === 'skip') { + return + } + + const environments = await listRuntimeEnvironmentsForAllHostLoad() + // Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber. + await Promise.all( + environments.map(async (environment) => { + const target = { + kind: 'environment' as const, + environmentId: environment.id + } + claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation) + const [catalogResult, visibilitySnapshot] = await Promise.all([ + fetchRepoCatalogForTarget(target).then( + (catalog) => ({ ok: true as const, catalog }), + (error: unknown) => ({ ok: false as const, error }) + ), + readRuntimeWorktreeVisibilitySnapshot(environment.id) + ]) + const visibilityDefaults = visibilitySnapshot.defaults + const hostId = getRuntimeTargetHostId(target) + if ( + visibilityDefaults !== undefined && + latestAllHostRepoCatalogGenerationByStore.get(get) === generation && + isLatestRepoCatalogGeneration(get, hostId, generation) + ) { + set((state) => + isRemovedRuntimeHostId(hostId, state.removedRuntimeEnvironmentIds) + ? state + : { + worktreeVisibilityDefaultsByHost: { + ...state.worktreeVisibilityDefaultsByHost, + [hostId]: visibilityDefaults + }, + ...(getActiveRuntimeTarget(state.settings).kind === 'environment' && + state.settings?.activeRuntimeEnvironmentId === environment.id + ? { + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: + visibilitySnapshot.sourceDefaultsSupported ? environment.id : null + } + : {}) + } + ) + } + if (catalogResult.ok) { + applyCatalog(catalogResult.catalog) + } else { + failed = true + console.warn( + `Skipped repos for runtime environment ${environment.id}:`, + catalogResult.error + ) + } + }) + ) + // Why: validate repo-scoped UI only after every host answers; first-paint loads only local repos, so an offline runtime would erase its saved filters. + if (!failed && get().reposFetchGeneration === generation) { + validateRepoScopedUi() + } + } + } +} diff --git a/src/renderer/src/store/repos/owner-routing.ts b/src/renderer/src/store/repos/owner-routing.ts new file mode 100644 index 00000000000..b611eacb41c --- /dev/null +++ b/src/renderer/src/store/repos/owner-routing.ts @@ -0,0 +1,101 @@ +import type { AppState } from '../types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import { FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' +import { findRepoForHost } from '../slices/repo-host-identity' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc +} from '../../runtime/runtime-rpc-client' +import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AddRepoPathRouteOptions } from './repo-state' +import { getRuntimeTargetHostId } from '../runtime-target-host' + +export function repoWithFetchedOwner( + repo: Repo, + target: ReturnType +): Repo { + if (target.kind === 'environment') { + return { ...repo, executionHostId: getRuntimeTargetHostId(target) } + } + if (repo.connectionId) { + return { ...repo, executionHostId: getRepoExecutionHostId(repo) } + } + return repo.executionHostId ? repo : { ...repo, executionHostId: LOCAL_EXECUTION_HOST_ID } +} + +export function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId +) { + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings, hostId }) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if ( + (parsed?.kind === 'local' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings +} + +export function getAddRepoPathRouteSettings( + options: AddRepoPathRouteOptions | undefined, + fallbackSettings: GlobalSettings | null +): Pick | null | undefined { + return options && 'runtimeEnvironmentId' in options + ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } + : fallbackSettings +} + +export function getRuntimeEnvironmentDisplayName(state: AppState, environmentId: string): string { + const environment = state.runtimeEnvironments.find((entry) => entry.id === environmentId) + return environment?.name || environmentId +} + +export async function fetchRuntimeAddProjectPathStatus(args: { + target: Extract, { kind: 'environment' }> + path: string +}): Promise { + await assertRuntimeEnvironmentCapability( + args.target.environmentId, + FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, + translate( + 'auto.store.slices.repos.2975400634', + 'Update Orca server to open non-Git folders on this runtime.' + ), + 15_000 + ) + try { + const { status } = await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>( + args.target, + 'folderWorkspace.getPathStatus', + { scope: 'path', path: args.path }, + { timeoutMs: 15_000 } + ) + return status + } catch (err) { + console.warn('Failed to check runtime folder path status:', err) + return null + } +} diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts new file mode 100644 index 00000000000..32b7aca4c60 --- /dev/null +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -0,0 +1,205 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getRepoHostIdentity } from '../slices/repo-host-identity' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' +import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import type { RepoSlice } from './repo-state' +import { ERROR_TOAST_DURATION } from './repo-state' +import { + fetchRuntimeAddProjectPathStatus, + getAddRepoPathRouteSettings, + getRuntimeEnvironmentDisplayName, + repoWithFetchedOwner +} from './owner-routing' +import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' +import { warnIfProjectKnownInAnotherProfile } from '../projects/project-profile-presence' + +export function createRepoAddActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + addRepoPath: async (path, kind = 'git', options) => { + try { + const target = getActiveRuntimeTarget(getAddRepoPathRouteSettings(options, get().settings)) + let repo: Repo + try { + if (target.kind === 'local') { + const result = await window.api.repos.add({ path, kind }) + if ('error' in result) { + throw new Error(result.error) + } + repo = result.repo + } else { + repo = ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.add', + { path, kind }, + { timeoutMs: 15_000 } + ) + ).repo + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (kind !== 'git' || !message.includes('Not a valid git repository')) { + throw err + } + if (target.kind !== 'local') { + const status = await fetchRuntimeAddProjectPathStatus({ target, path }) + if (status?.exists !== true) { + const hostName = getRuntimeEnvironmentDisplayName(get(), target.environmentId) + toast.error( + translate( + 'auto.store.slices.repos.3be0f7df04', + 'Cannot open folder on selected runtime' + ), + { + description: translate( + 'auto.store.slices.repos.15cf5319ec', + '{{path}} was checked on {{hostName}}, but that host did not report a usable folder.', + { path, hostName } + ), + duration: ERROR_TOAST_DURATION + } + ) + return null + } + } + // Why: folder mode is a capability downgrade (no worktrees/SCM/PRs/checks), so confirm via dialog rather than silently falling back. + const { openModal } = get() + openModal('confirm-non-git-folder', { + folderPath: path, + ...(target.kind === 'environment' ? { runtimeEnvironmentId: target.environmentId } : {}) + }) + return null + } + repo = repoWithFetchedOwner(repo, target) + const repoIdentity = getRepoHostIdentity(repo) + const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) + if (alreadyAdded) { + get().clearOrcaHookTrustForRepo(repo.id) + } + set((s) => { + if (s.repos.some((r) => getRepoHostIdentity(r) === repoIdentity)) { + return s + } + const nextRepos = [...s.repos, repo] + const hostId = getRepoExecutionHostId(repo) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId + }), + folderWorkspacePathStatuses: {} + } + }) + if (alreadyAdded) { + toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), { + description: repo.displayName + }) + } else { + toast.success( + isGitRepoKind(repo) + ? translate('auto.store.slices.repos.8bb3ad7935', 'Project added') + : translate('auto.store.slices.repos.90d129b48b', 'Folder added'), + { + description: repo.displayName + } + ) + // Why: the cross-profile advisory applies to SSH-added projects too; the presence lookup already keys on connection/host. + await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) + } + return repo + } catch (err) { + console.error('Failed to add project:', err) + const message = err instanceof Error ? err.message : String(err) + const duration = ERROR_TOAST_DURATION + toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { + description: message, + duration + }) + return null + } + }, + + addRepo: async () => { + const target = getActiveRuntimeTarget(get().settings) + if (target.kind !== 'local') { + // Why: OS folder pickers return client-local paths; remote environments need an explicit host path (Add Project dialog). + toast.error( + translate( + 'auto.store.slices.repos.e649269645', + 'Use Add Project to enter a path on the selected host.' + ) + ) + return null + } + const path = await window.api.repos.pickFolder() + if (!path) { + return null + } + return get().addRepoPath(path) + }, + + addNonGitFolder: async (path, options) => { + try { + const hadProjectBeforeAdd = get().repos.length > 0 + const repo = await get().addRepoPath(path, 'folder', options) + if (!repo) { + return null + } + await markOnboardingProjectAdded('addedFolder') + // Why: focus the new folder so the add is visible; lazy-import worktree-activation to avoid a circular module load (it imports the store root). + const executionHostId = + options?.runtimeEnvironmentId === undefined + ? undefined + : options.runtimeEnvironmentId + ? toRuntimeExecutionHostId(options.runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + await get().fetchWorktrees(repo.id, executionHostId ? { executionHostId } : undefined) + const folderWorktree = get().worktreesByRepo[repo.id]?.find( + (worktree) => executionHostId === undefined || worktree.hostId === executionHostId + ) + if (folderWorktree) { + const { activateAndRevealWorktree } = await import('../../lib/worktree-activation') + const onboarding = await window.api.onboarding.get().catch(() => null) + // Why: adding the first folder from Landing skips onboarding's completeRepo hook; carry the default agent into the first terminal here. + const startup = buildDismissedOnboardingFolderAgentStartup( + get().settings, + onboarding, + hadProjectBeforeAdd, + isNativeChatTranscriptLocalReadable(repo.connectionId) + ) + activateAndRevealWorktree(folderWorktree.id, { + sidebarRevealBehavior: 'auto', + ...(executionHostId ? { executionHostId } : {}), + ...(startup ? { startup } : {}) + }) + } + return repo + } catch (err) { + console.error('Failed to add folder:', err) + const message = err instanceof Error ? err.message : String(err) + toast.error(translate('auto.store.slices.repos.b7e14472ae', 'Failed to add folder'), { + description: message, + duration: ERROR_TOAST_DURATION + }) + return null + } + } + } +} diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts new file mode 100644 index 00000000000..cd5368d0c11 --- /dev/null +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -0,0 +1,173 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' +import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' +import { + mergeSshRepoReadoptions, + reconcileReadoptedSshRepoRows +} from '../slices/superseded-ssh-repo-rows' +import { getRepoHostIdentity } from '../slices/repo-host-identity' +import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' +import type { LocalRepoCatalogFetchOutcome } from './repo-catalog-fencing' +import type { RepoSlice } from './repo-state' +import { arrayElementsUnchanged } from '../catalog-identity' +import { + awaitLatestLocalRepoCatalogFetch, + claimRepoCatalogGeneration, + isLatestRepoCatalogGeneration, + startLocalRepoCatalogFetch +} from './repo-catalog-fencing' +import { + fetchRepoCatalogForTarget, + filterSetupsForPrunedRepoRows, + mergeFetchedRepoCatalog, + projectCompatibilityForReconciledRepos, + reconcileReadoptedSshWorktreeState, + reconcileSupersededSshRepos +} from './repo-catalog-merge' +import { + getProjectHostSetupOwnerKey, + mergeProjectHostSetupCompatibility, + projectCompatibilityFromRepos +} from '../projects/project-compatibility-core' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' + +export function createRepoCatalogActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + recordSshRepoReadoptions: (readoptions) => + set((s) => { + // Why: SshPane importConfig() often reports [] on every Manage-pane open. + if (readoptions.length === 0 && s.pendingSshRepoReadoptions.length === 0) { + return s + } + const pendingSshRepoReadoptions = mergeSshRepoReadoptions( + s.pendingSshRepoReadoptions, + readoptions + ) + const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions) + const repos = reconciliation.repos + const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions) + const remainingSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos) + const compatibility = mergeProjectHostSetupCompatibility( + projectCompatibilityFromRepos(repos), + { + projects: s.projects, + setups: remainingSetups + } + ) + // Why: mergeProjectHostSetupCompatibility always allocates; a no-op readoption + // must not churn catalog identity. This write is all-repos, not host-scoped, + // so it cannot go through mergeFetchedProjectCompatibilityForHost. Reconcile + // hands the store arrays straight back when nothing moved, so writing them + // unconditionally still leaves identity-keyed selectors untouched. + const projects = reconcileCatalogRows( + s.projects, + compatibility.projects, + (project) => project.id + ) + const projectHostSetups = reconcileCatalogRows( + s.projectHostSetups, + compatibility.projectHostSetups, + getProjectHostSetupOwnerKey + ) + return { + repos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...worktreeState, + projects, + projectHostSetups + } + }), + + fetchRepos: async (options) => { + const target = getActiveRuntimeTarget( + settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) + ) + const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void = + target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined + let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } + // Why: overlapping repos:changed fetches can resolve out of order; a stale one must not overwrite a newer result and resurrect deleted projects (#7020). + let generation = 0 + set((s) => { + generation = s.reposFetchGeneration + 1 + return { reposFetchGeneration: generation } + }) + const targetHostId = getRuntimeTargetHostId(target) + claimRepoCatalogGeneration(get, targetHostId, generation) + try { + const catalog = await fetchRepoCatalogForTarget(target) + // A newer same-host fetch superseded us while we awaited — drop this stale result. + if (!isLatestRepoCatalogGeneration(get, targetHostId, generation)) { + return + } + let finalizedHostRepos: Repo[] = [] + set((s) => { + // Why: an in-flight fetch for a just-removed env would re-add purged repos and stick; skip only when the env was tombstoned, not merely unhydrated (#8881). + if (isRemovedRuntimeHostId(catalog.hostId, s.removedRuntimeEnvironmentIds)) { + return s + } + // Why: re-adoption leaves a stale row on the old SSH target id (a ghost that fails "SSH target not found"); drop rows a live-host sibling supersedes. + const result = mergeFetchedRepoCatalog(catalog, s.repos) + const reconciliation = reconcileSupersededSshRepos(result.repos, s) + const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) + const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) + const projectCompatibility = projectCompatibilityForReconciledRepos( + prunedRepos, + catalog.projectHostSetupCompatibility + ) + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: filterSetupsForPrunedRepoRows( + s.projectHostSetups, + result.repos, + prunedRepos + ) + }, + fetched: projectCompatibility, + repos: prunedRepos, + hostId: result.hostId + }) + finalizedHostRepos = prunedRepos.filter( + (repo) => getRepoExecutionHostId(repo) === result.hostId + ) + return { + repos: prunedRepos, + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), + ...mergedProjectCompatibility, + ...(arrayElementsUnchanged(prunedRepos, s.repos) + ? {} + : { folderWorkspacePathStatuses: {} }), + activeRepoId: + s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ) + } + }) + scheduleSafeAutoForkSync(get, finalizedHostRepos) + } catch (err) { + localCatalogOutcome = { status: 'rejected', reason: err } + console.error('Failed to fetch repos:', err) + } finally { + settleLocalCatalog(localCatalogOutcome) + } + }, + + awaitLocalRepoCatalogSettlement: () => awaitLatestLocalRepoCatalogFetch(get) + } +} diff --git a/src/renderer/src/store/repos/repo-catalog-fencing.ts b/src/renderer/src/store/repos/repo-catalog-fencing.ts new file mode 100644 index 00000000000..38693085fce --- /dev/null +++ b/src/renderer/src/store/repos/repo-catalog-fencing.ts @@ -0,0 +1,66 @@ +import type { AppState } from '../types' +export type LocalRepoCatalogFetchOutcome = + | { status: 'fulfilled' } + | { status: 'rejected'; reason: unknown } + +export const latestLocalRepoCatalogFetchByStore = new WeakMap< + () => AppState, + Promise +>() + +export const latestRepoCatalogGenerationByHostByStore = new WeakMap< + () => AppState, + Map +>() + +export const latestAllHostRepoCatalogGenerationByStore = new WeakMap<() => AppState, number>() + +export function startLocalRepoCatalogFetch( + get: () => AppState +): (outcome: LocalRepoCatalogFetchOutcome) => void { + let settle: (outcome: LocalRepoCatalogFetchOutcome) => void = () => undefined + const settlement = new Promise((resolve) => { + settle = resolve + }) + latestLocalRepoCatalogFetchByStore.set(get, settlement) + return settle +} + +export async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise { + while (true) { + const pending = latestLocalRepoCatalogFetchByStore.get(get) + if (!pending) { + return + } + const outcome = await pending + if (latestLocalRepoCatalogFetchByStore.get(get) === pending) { + if (outcome.status === 'rejected') { + throw outcome.reason + } + return + } + } +} + +export function claimRepoCatalogGeneration( + get: () => AppState, + hostId: string, + generation: number +): void { + let generations = latestRepoCatalogGenerationByHostByStore.get(get) + if (!generations) { + generations = new Map() + latestRepoCatalogGenerationByHostByStore.set(get, generations) + } + if ((generations.get(hostId) ?? 0) < generation) { + generations.set(hostId, generation) + } +} + +export function isLatestRepoCatalogGeneration( + get: () => AppState, + hostId: string, + generation: number +): boolean { + return latestRepoCatalogGenerationByHostByStore.get(get)?.get(hostId) === generation +} diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts new file mode 100644 index 00000000000..a5c838f630a --- /dev/null +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -0,0 +1,78 @@ +import type { Repo } from '../../../../shared/repo-types' +import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' +import { reconcileFetchedRepos } from '../slices/repo-identity-reconcile' +import { getRepoHostIdentity } from '../slices/repo-host-identity' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { RepoSlice } from './repo-state' +import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +import { projectCompatibilityFromRepos } from '../projects/project-compatibility-core' +import { mergeByIdentity } from '../catalog-identity' + +export function mergeFetchedReposForHost( + previous: readonly Repo[], + fetched: readonly Repo[], + hostId: string +): readonly Repo[] { + const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched) + const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoHostIdentity)) + const preserved = previous.filter((repo) => { + const existingHostId = getRepoExecutionHostId(repo) + return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) + }) + return reconcileFetchedRepos( + previous, + mergeByIdentity(preserved, fetchedWithProjectGroups, getRepoHostIdentity) + ) +} + +export function applyInheritedProjectGroups( + previous: readonly Repo[], + fetched: readonly Repo[] +): Repo[] { + const projectGroupIdByProject = new Map() + for (const repo of previous) { + const projectGroupId = + repo.projectGroupId === undefined ? undefined : (repo.projectGroupId ?? null) + if (projectGroupId === undefined) { + continue + } + const projectId = getProjectIdentityKey(repo) + if (projectId.startsWith('repo:')) { + continue + } + if (!projectGroupIdByProject.has(projectId)) { + projectGroupIdByProject.set(projectId, projectGroupId) + } + } + if (projectGroupIdByProject.size === 0) { + return [...fetched] + } + return fetched.map((repo) => { + if (repo.projectGroupId !== undefined) { + return repo + } + const inheritedProjectGroupId = projectGroupIdByProject.get(getProjectIdentityKey(repo)) + if (inheritedProjectGroupId === undefined) { + return repo + } + // Why: project groups are a local affordance; runtime copies of the same canonical project should appear in the user's existing group. + return { ...repo, projectGroupId: inheritedProjectGroupId } + }) +} + +export function mergeProjectCompatibilityForHostRepoChange({ + previous, + nextRepos, + hostId +}: { + previous: Pick + nextRepos: readonly Repo[] + hostId: string +}): Pick { + return mergeFetchedProjectCompatibilityForHost({ + previous, + fetched: projectCompatibilityFromRepos(nextRepos), + repos: nextRepos, + hostId + }) +} diff --git a/src/renderer/src/store/repos/repo-catalog-merge.ts b/src/renderer/src/store/repos/repo-catalog-merge.ts new file mode 100644 index 00000000000..f08f944d6ef --- /dev/null +++ b/src/renderer/src/store/repos/repo-catalog-merge.ts @@ -0,0 +1,141 @@ +import type { AppState } from '../types' +import type { SshRepoReadoption } from '../../../../shared/ssh-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { reconcileReadoptedSshRepoRows } from '../slices/superseded-ssh-repo-rows' +import type { SshRepoReconciliation } from '../slices/superseded-ssh-repo-rows' +import { reconcileReadoptedSshWorktreesByRepo } from '../slices/readopted-ssh-worktree-rows' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { RepoSlice } from './repo-state' +import { repoWithFetchedOwner } from './owner-routing' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { fetchProjectHostSetupCompatibility } from '../projects/project-host-routing' +import { mergeFetchedReposForHost } from './repo-catalog-identity' +import { + mergeProjectHostSetupCompatibility, + projectCompatibilityFromRepos +} from '../projects/project-compatibility-core' + +export type FetchedRepoCatalog = { + repos: readonly Repo[] + projectHostSetupCompatibility: ProjectHostSetupProjection + hostId: ReturnType +} + +export async function fetchRepoCatalogForTarget( + target: ReturnType +): Promise { + const fetchedRepos = + target.kind === 'local' + ? await window.api.repos.list() + : ( + await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: true + }) + ).repos + const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) + return { + repos, + projectHostSetupCompatibility: await fetchProjectHostSetupCompatibility(target, repos), + hostId: getRuntimeTargetHostId(target) + } +} + +export function mergeFetchedRepoCatalog( + catalog: FetchedRepoCatalog, + currentRepos: readonly Repo[] +): { + repos: readonly Repo[] + projectHostSetupCompatibility: ProjectHostSetupProjection + hostId: ReturnType +} { + const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId) + return { + repos, + projectHostSetupCompatibility: catalog.projectHostSetupCompatibility, + hostId: catalog.hostId + } +} + +export function reconcileSupersededSshRepos( + repos: readonly Repo[], + state: Pick +): SshRepoReconciliation { + return reconcileReadoptedSshRepoRows(repos, state.pendingSshRepoReadoptions) +} + +export function filterSetupsForPrunedRepoRows( + setups: readonly ProjectHostSetup[], + mergedRepos: readonly Repo[], + reconciledRepos: readonly Repo[] +): readonly ProjectHostSetup[] { + const survivingOwners = new Set( + reconciledRepos.map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`) + ) + const prunedOwners = new Set( + mergedRepos + .filter((repo) => !survivingOwners.has(`${getRepoExecutionHostId(repo)}:${repo.id}`)) + .map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`) + ) + // Why: this result feeds the compat merge as `previous`, so an unconditional copy would discard + // the identity that merge is about to try to preserve. + if (prunedOwners.size === 0) { + return setups + } + const filtered = setups.filter( + (setup) => !setup.repoId || !prunedOwners.has(`${setup.hostId}:${setup.repoId}`) + ) + return filtered.length === setups.length ? setups : filtered +} + +export function reconcileReadoptedSshWorktreeState( + state: Pick, + readoptions: readonly SshRepoReadoption[] +): Pick { + const worktreesByRepo = reconcileReadoptedSshWorktreesByRepo(state.worktreesByRepo, readoptions) + const detectedRows = Object.fromEntries( + Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + result.worktrees + ]) + ) + const reconciledDetectedRows = reconcileReadoptedSshWorktreesByRepo(detectedRows, readoptions) + const detectedWorktreesByRepo = + reconciledDetectedRows === detectedRows + ? state.detectedWorktreesByRepo + : Object.fromEntries( + Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + { ...result, worktrees: reconciledDetectedRows[repoId] } + ]) + ) + return { + worktreesByRepo, + detectedWorktreesByRepo, + sortEpoch: worktreesByRepo === state.worktreesByRepo ? state.sortEpoch : state.sortEpoch + 1 + } +} + +export function projectCompatibilityForReconciledRepos( + repos: readonly Repo[], + fetched: ProjectHostSetupProjection +): Pick { + return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched) +} + +export function filterTrustedOrcaHooksToValidRepos( + trust: AppState['trustedOrcaHooks'], + validRepoIds: Set +): AppState['trustedOrcaHooks'] { + const next: AppState['trustedOrcaHooks'] = {} + for (const [repoId, entry] of Object.entries(trust)) { + if (validRepoIds.has(repoId)) { + next[repoId] = entry + } + } + return next +} diff --git a/src/renderer/src/store/repos/repo-ordering.ts b/src/renderer/src/store/repos/repo-ordering.ts new file mode 100644 index 00000000000..061e2354a7d --- /dev/null +++ b/src/renderer/src/store/repos/repo-ordering.ts @@ -0,0 +1,86 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { getManualRepoOrder } from '../../../../shared/manual-repo-order' +import { splitRepoReorderByHost } from '../slices/repo-reorder-host-split' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { RepoSlice } from './repo-state' + +export function createRepoOrderingActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + setActiveRepo: (projectId) => set({ activeRepoId: projectId }), + + reorderRepos: async (orderedIds) => { + // Optimistically apply the new order for instant sidebar update; resync only if main rejects (racing add/remove). + const previous = get().repos + const remainingById = new Map() + for (const repo of previous) { + const existing = remainingById.get(repo.id) + if (existing) { + existing.repos.push(repo) + } else { + remainingById.set(repo.id, { repos: [repo], nextIndex: 0 }) + } + } + const next: Repo[] = [] + for (const id of orderedIds) { + const remaining = remainingById.get(id) + const repo = remaining?.repos[remaining.nextIndex] + if (remaining) { + remaining.nextIndex += 1 + } + if (repo) { + next.push(repo) + } + } + if (next.length !== previous.length) { + // Caller passed a non-permutation — refuse to apply locally. + return + } + const manualRepoOrder = getManualRepoOrder(next) + set({ + repos: next, + manualRepoOrder, + folderWorkspacePathStatuses: {} + }) + try { + // Why: each host persists only its own repos and rejects non-permutations; dispatch one per-host permutation per owner. + const groups = splitRepoReorderByHost(orderedIds, next, get().settings) + const [results] = await Promise.all([ + Promise.all( + groups.map(async (group) => { + const parsed = parseExecutionHostId(group.hostId) + const target = + parsed?.kind === 'runtime' + ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) + : ({ kind: 'local' } as const) + return target.kind === 'local' + ? window.api.repos.reorderForHost({ + hostId: group.hostId, + orderedIds: group.orderedIds + }) + : callRuntimeRpc<{ status: 'applied' | 'rejected' }>( + target, + 'repo.reorder', + { orderedIds: group.orderedIds }, + { timeoutMs: 15_000 } + ) + }) + ), + // Why: servers only persist local permutations; the desktop profile owns cross-host order after a cold load. + window.api.ui.set({ manualRepoOrder }) + ]) + if (results.some((result) => result.status === 'rejected')) { + await get().fetchReposForAllHosts() + } + } catch (err) { + console.error('Failed to reorder repos:', err) + await get().fetchReposForAllHosts() + } + } + } +} diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts new file mode 100644 index 00000000000..48d8144c516 --- /dev/null +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -0,0 +1,284 @@ +import type { StateCreator } from 'zustand' +import { toast } from 'sonner' +import type { AppState } from '../types' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' +import { omitSparsePresetsForRepos } from '../slices/sparse-presets' +import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + hasRuntimeRpcErrorCode +} from '../../runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' +import { + getRepoExecutionHostId, + isRuntimeOwnedSshTargetId, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' +import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' +import type { RepoSlice } from './repo-state' +import { ERROR_TOAST_DURATION } from './repo-state' +import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' +import { settingsForRepoOwner } from './owner-routing' + +export function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean { + return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId +} + +export function getKnownRepoWorktreeIds( + state: AppState, + projectId: string, + hostId?: string +): string[] { + const ids = new Set() + for (const worktree of state.worktreesByRepo[projectId] ?? []) { + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } + } + for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) { + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } + } + return [...ids] +} + +export function createRepoRemovalActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + removeProject: async (projectId, options) => { + try { + // Why: pass an explicit hostId so a duplicate id across hosts resolves to the intended row, not the focused-host fallback. + const ownerRepo = findRepoForHost(get().repos, projectId, { + settings: get().settings, + hostId: options?.hostId + }) + if (!ownerRepo) { + return + } + const ownerHostId = getRepoExecutionHostId(ownerRepo) + const runtimeSshTargetId = ownerRepo.connectionId + // Why: an SSH per-workspace-env's workspace is the repo's main worktree, so removal routes here; tear down its ephemeral runtime first so it doesn't leak. + if (runtimeSshTargetId && isRuntimeOwnedSshTargetId(runtimeSshTargetId)) { + const cleanup = await cleanupEphemeralVmRuntimesForDeleted({ + workspaceIds: getKnownRepoWorktreeIds(get(), projectId, ownerHostId), + runtimeOwnedSshTargetIds: [runtimeSshTargetId] + }) + if (cleanup.retainedSshTargetIds.includes(runtimeSshTargetId)) { + throw new Error( + 'The cloud VM could not be destroyed. Retry cleanup before removing it.' + ) + } + } + // Why: derive the target from the owner's settings (via options.hostId) so an SSH host removal never routes repo.rm to the focused runtime. + const target = getActiveRuntimeTarget( + settingsForRepoOwner(get(), projectId, options?.hostId) + ) + // Why: repos:remove is id-only and would delete every host's row; scope local removal to the owning host so cross-host duplicates keep other rows. + const idExistsOnOtherHost = get().repos.some( + (repo) => repo.id === projectId && getRepoExecutionHostId(repo) !== ownerHostId + ) + try { + await (target.kind === 'local' + ? idExistsOnOtherHost + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) + : window.api.repos.remove({ repoId: projectId }) + : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + } catch (err) { + // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). + if (!hasRuntimeRpcErrorCode(err, 'repo_not_found')) { + throw err + } + } + + get().clearOrcaHookTrustForRepo(projectId) + const repoPath = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, ownerHostId) + )?.path + get().evictGitHubRepoCaches(projectId, repoPath) + const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index') + clearRepoSlugCacheEntry(projectId) + + // Kill PTYs for all worktrees belonging to this repo + const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) + // A raw id can be published by two hosts. Keep the purge host-scoped for + // those twins so the sibling's qualified visit recency survives. + const knownRepoWorktrees = [ + ...(get().worktreesByRepo[projectId] ?? []), + ...(get().detectedWorktreesByRepo[projectId]?.worktrees ?? []) + ] + const exactSiblingIds = new Set( + knownRepoWorktrees + .filter((worktree) => !worktreeBelongsToHost(worktree, ownerHostId)) + .map((worktree) => worktree.id) + ) + const purgeTargets = worktreeIds.map((id) => + exactSiblingIds.has(id) ? { id, hostId: ownerHostId } : id + ) + const localAgentContextProjectIds = + ownerHostId === LOCAL_EXECUTION_HOST_ID + ? [ + projectId, + ...(get().worktreesByRepo[projectId] ?? []) + .filter((worktree) => worktreeBelongsToHost(worktree, ownerHostId)) + .flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : [])) + ] + : [] + const killedTabIds = new Set() + if (target.kind === 'environment') { + await Promise.allSettled( + worktreeIds.map((worktreeId) => + callRuntimeRpc( + target, + 'terminal.stop', + { worktree: toRuntimeWorktreeSelector(worktreeId) }, + { timeoutMs: 15_000 } + ) + ) + ) + } + for (const wId of worktreeIds) { + const tabs = get().tabsByWorktree[wId] ?? [] + for (const tab of tabs) { + killedTabIds.add(tab.id) + for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) { + if (!ptyId.startsWith('remote:')) { + window.api.pty.kill(ptyId) + } + } + } + } + + // Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree. + get().purgeWorktreeTerminalState(purgeTargets) + get().clearLocalDetectedAgentContextsForProjects(localAgentContextProjectIds) + + set((s) => { + const nextWorktrees = { ...s.worktreesByRepo } + const remainingWorktrees = (nextWorktrees[projectId] ?? []).filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingWorktrees.length > 0) { + nextWorktrees[projectId] = remainingWorktrees + } else { + delete nextWorktrees[projectId] + } + const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo } + const detected = nextDetectedWorktrees[projectId] + if (detected) { + const remainingDetected = detected.worktrees.filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingDetected.length > 0) { + nextDetectedWorktrees[projectId] = { ...detected, worktrees: remainingDetected } + } else { + delete nextDetectedWorktrees[projectId] + } + } + const nextTabs = { ...s.tabsByWorktree } + const nextLayouts = { ...s.terminalLayoutsByTabId } + const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } + const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + for (const wId of worktreeIds) { + delete nextTabs[wId] + } + for (const tabId of killedTabIds) { + delete nextLayouts[tabId] + delete nextPtyIdsByTabId[tabId] + delete nextRuntimePaneTitlesByTabId[tabId] + } + // Why: editor state is worktree-scoped; clear the repo's open files + active-file tracking so orphans don't linger in the session save. + const worktreeIdSet = new Set(worktreeIds) + const removedVisitKeys = new Set( + worktreeIds.map((worktreeId) => getWorktreeVisitKey(worktreeId, ownerHostId)) + ) + const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId)) + const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + for (const wId of worktreeIds) { + delete nextActiveFileIdByWorktree[wId] + delete nextActiveTabTypeByWorktree[wId] + } + const activeFileCleared = s.activeFileId + ? s.openFiles.some((f) => f.id === s.activeFileId && worktreeIdSet.has(f.worktreeId)) + : false + const nextRepos = s.repos.filter( + (r) => !repoMatchesHostIdentity(r, projectId, ownerHostId) + ) + // Why: when no sibling host owns this id, drop every worktree timestamp (unhydrated SSH ones would otherwise never prune); else stay host-scoped. + const repoIdFullyRemoved = !nextRepos.some((r) => r.id === projectId) + let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId + for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { + const rawId = getWorktreeIdFromVisitKey(id) + if ( + (ownerHostId && removedVisitKeys.has(id)) || + (!ownerHostId && worktreeIdSet.has(rawId)) || + (repoIdFullyRemoved && getRepoIdFromWorktreeId(rawId) === projectId) + ) { + if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { + nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } + } + delete nextLastVisitedAtByWorktreeId[id] + } + } + const survivingRepoIds = new Set(nextRepos.map((r) => r.id)) + const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) + return { + repos: nextRepos, + // Why: drop removed repos' sparse-preset maps so they don't outlive the repo for the whole session. + ...omitSparsePresetsForRepos(s, removedRepoIds), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), + activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, + filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), + worktreesByRepo: nextWorktrees, + detectedWorktreesByRepo: nextDetectedWorktrees, + tabsByWorktree: nextTabs, + ptyIdsByTabId: nextPtyIdsByTabId, + runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + terminalLayoutsByTabId: nextLayouts, + activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId, + openFiles: nextOpenFiles, + activeFileIdByWorktree: nextActiveFileIdByWorktree, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + activeFileId: activeFileCleared ? null : s.activeFileId, + activeTabType: activeFileCleared ? 'terminal' : s.activeTabType, + lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId, + folderWorkspacePathStatuses: {}, + sortEpoch: s.sortEpoch + 1, + // Why: removing the last repo must reset activeView + clear activeWorktreeId so App renders Landing, not an empty settings/terminal pane. + ...(nextRepos.length === 0 + ? { + activeView: 'terminal' as const, + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeRepoId: null + } + : {}) + } + }) + } catch (err) { + console.error('Failed to remove repo:', err) + // Why: bulk and background callers aggregate their own failures, so only opted-in single-project entry points toast (#11994). + if (options?.errorFeedback === 'toast') { + toast.error( + translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), + { + description: err instanceof Error ? err.message : String(err), + duration: ERROR_TOAST_DURATION + } + ) + } + } + } + } +} diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts new file mode 100644 index 00000000000..cc89bb084be --- /dev/null +++ b/src/renderer/src/store/repos/repo-state.ts @@ -0,0 +1,258 @@ +import type { SshRepoReadoption } from '../../../../shared/ssh-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { + NestedRepoScanResult, + ProjectGroup, + ProjectGroupImportResult +} from '../../../../shared/project-group-types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCloneArgs, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupExistingFolderArgs, + ProjectHostSetupResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { + FolderWorkspacePathStatus, + FolderWorkspacePathStatusRequest +} from '../../../../shared/folder-workspace-path-status' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +export const ERROR_TOAST_DURATION = 60_000 + +export type RepoUpdate = Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + > +> & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null +} + +export type ProjectUpdate = ProjectUpdateArgs['updates'] + +export type FolderWorkspaceUpdates = Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'linkedTaskSourceContext' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + | 'diffComments' + > +> + +export type NestedRepoScanControls = { + scanId?: string + onProgress?: (scan: NestedRepoScanResult) => void + runtimeEnvironmentId?: string | null +} + +export type NestedRepoScanCancelOptions = { + runtimeEnvironmentId?: string | null +} + +export type FolderWorkspacePathStatusCacheEntry = { + status: FolderWorkspacePathStatus + checkedAt: number + requestSnapshot: string +} + +export type DeleteProjectGroupWithContainedProjectsOptions = { + removeContainedProjects: boolean + // hostId disambiguates which host's group row to delete when the id exists on multiple hosts. + hostId?: ExecutionHostId +} + +export type AllHostCatalogFetchOptions = { + remoteHosts?: 'include' | 'skip' +} + +export type ProjectRemovalFailure = { + projectId: string + reason: string +} + +export type DeleteProjectGroupWithContainedProjectsResult = + | { + status: 'deleted-group' + groupId: string + requestedProjectIds: string[] + removedProjectIds: string[] + failedProjectRemovals: ProjectRemovalFailure[] + } + | { + status: 'missing-group' | 'group-delete-failed' + groupId: string + requestedProjectIds: string[] + removedProjectIds: [] + failedProjectRemovals: [] + } + +export type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId?: string | null } + +export type AddRepoPathRouteOptions = { runtimeEnvironmentId?: string | null } + +export type RuntimeCatalogFetchOptions = { runtimeEnvironmentId?: string | null } + +export type RepoSlice = { + repos: readonly Repo[] + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + folderWorkspacePathStatuses: Record + activeRepoId: string | null + // Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020). + reposFetchGeneration: number + pendingSshRepoReadoptions: readonly SshRepoReadoption[] + recordSshRepoReadoptions: (readoptions: readonly SshRepoReadoption[]) => void + fetchRepos: (options?: RuntimeCatalogFetchOptions) => Promise + fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise + awaitLocalRepoCatalogSettlement: () => Promise + fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise + fetchProjectGroups: (options?: RuntimeCatalogFetchOptions) => Promise + fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise + fetchFolderWorkspaces: (options?: RuntimeCatalogFetchOptions) => Promise + fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise + addRepo: () => Promise + addRepoPath: ( + path: string, + kind?: 'git' | 'folder', + options?: AddRepoPathRouteOptions + ) => Promise + setupProjectExistingFolder: ( + args: ProjectHostSetupExistingFolderArgs + ) => Promise + createProjectHostSetup: ( + args: ProjectHostSetupCreateArgs + ) => Promise + updateProjectHostSetup: ( + args: ProjectHostSetupUpdateArgs + ) => Promise + deleteProjectHostSetup: ( + args: ProjectHostSetupDeleteArgs + ) => Promise + setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise + addNonGitFolder: (path: string, options?: AddRepoPathRouteOptions) => Promise + scanNestedRepos: ( + path: string, + connectionId?: string, + controls?: NestedRepoScanControls + ) => Promise + cancelNestedRepoScan: (scanId: string, options?: NestedRepoScanCancelOptions) => Promise + importNestedRepos: (args: { + parentPath: string + groupName: string + projectPaths: string[] + connectionId?: string + scanId?: string + runtimeEnvironmentId?: string | null + mode: 'group' | 'separate' + }) => Promise + createProjectGroup: (name: string) => Promise + createFolderWorkspace: ( + args: { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] + createdWithAgent?: FolderWorkspace['createdWithAgent'] + pendingFirstAgentMessageRename?: boolean + }, + options?: FolderWorkspacePathStatusRouteOptions + ) => Promise + getFolderWorkspacePathStatusCacheKey: ( + request: FolderWorkspacePathStatusRequest, + options?: FolderWorkspacePathStatusRouteOptions + ) => string + getFreshFolderWorkspacePathStatus: ( + request: FolderWorkspacePathStatusRequest, + options?: FolderWorkspacePathStatusRouteOptions + ) => FolderWorkspacePathStatus | null + fetchFolderWorkspacePathStatus: ( + request: FolderWorkspacePathStatusRequest, + options?: { force?: boolean } & FolderWorkspacePathStatusRouteOptions + ) => Promise + updateFolderWorkspace: ( + folderWorkspaceId: string, + updates: FolderWorkspaceUpdates, + options?: { executionHostId?: ExecutionHostId } + ) => Promise + deleteFolderWorkspace: ( + folderWorkspaceId: string, + options?: { executionHostId?: ExecutionHostId } + ) => Promise + // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call. + updateProjectGroup: ( + groupId: string, + updates: Partial>, + options?: { hostId?: ExecutionHostId } + ) => Promise + deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise + deleteProjectGroupWithContainedProjects: ( + groupId: string, + options: DeleteProjectGroupWithContainedProjectsOptions + ) => Promise + moveProjectToGroup: ( + projectId: string, + groupId: string | null, + order?: number + ) => Promise + // options.hostId disambiguates which host's row to remove when the id exists on multiple hosts; else the focused host is assumed. + // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. + removeProject: ( + projectId: string, + options?: { hostId?: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } + ) => Promise + updateProject: (projectId: string, updates: ProjectUpdate) => Promise + // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. + updateRepo: ( + projectId: string, + updates: RepoUpdate, + options?: { hostId?: ExecutionHostId } + ) => Promise + setActiveRepo: (projectId: string | null) => void + reorderRepos: (orderedIds: string[]) => Promise +} diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts new file mode 100644 index 00000000000..29748348852 --- /dev/null +++ b/src/renderer/src/store/repos/repo-update.ts @@ -0,0 +1,216 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { + findRepoForHost, + getRepoHostIdentityForParts, + repoMatchesHostIdentity +} from '../slices/repo-host-identity' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences +} from '../../../../shared/worktree/visibility-sources' +import type { RepoSlice, RepoUpdate } from './repo-state' +import { repoWithFetchedOwner, settingsForRepoOwner } from './owner-routing' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { getProjectSetupRuntimeTarget } from '../projects/project-host-routing' +import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' + +export function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { + const sanitized = { ...updates } + if ('badgeColor' in sanitized) { + const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) + if (!badgeColor) { + delete sanitized.badgeColor + } else { + sanitized.badgeColor = badgeColor + } + } + if ('repoIcon' in sanitized) { + const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) + if (repoIcon === undefined) { + delete sanitized.repoIcon + } else { + sanitized.repoIcon = repoIcon + } + } + if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { + sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined + } + if ( + 'forkSyncMode' in sanitized && + sanitized.forkSyncMode !== undefined && + sanitized.forkSyncMode !== 'ask' && + sanitized.forkSyncMode !== 'safe-auto' && + sanitized.forkSyncMode !== 'off' + ) { + delete sanitized.forkSyncMode + } + if ('customWorktreeVisibilitySources' in sanitized) { + const sources = normalizeCustomWorktreeVisibilitySources( + sanitized.customWorktreeVisibilitySources + ) + if (!sources) { + delete sanitized.customWorktreeVisibilitySources + } else { + sanitized.customWorktreeVisibilitySources = sources + } + } + if ('worktreeVisibilitySourcePreferences' in sanitized) { + const preferences = normalizeWorktreeVisibilitySourcePreferences( + sanitized.worktreeVisibilitySourcePreferences + ) + if (!preferences) { + delete sanitized.worktreeVisibilitySourcePreferences + } else { + sanitized.worktreeVisibilitySourcePreferences = preferences + } + } + return sanitized +} + +export const updateRepoChainsByStore = new WeakMap<() => AppState, Map>>() + +export function getRepoUpdateChains(get: () => AppState): Map> { + let chains = updateRepoChainsByStore.get(get) + if (!chains) { + chains = new Map>() + updateRepoChainsByStore.set(get, chains) + } + return chains +} + +export function createRepoUpdateActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + updateRepo: async (projectId, updates, options) => { + const updateRepoChains = getRepoUpdateChains(get) + // Why: pass options.hostId so a duplicate repo id across hosts resolves to the intended row, not the settings-focused fallback. + const ownerRepo = findRepoForHost(get().repos, projectId, { + settings: get().settings, + hostId: options?.hostId + }) + if (!ownerRepo) { + return false + } + // Why: an explicit hostId is authoritative; route to that host's target rather than the currently-focused runtime. + const ownerHasExplicitHost = Boolean( + options?.hostId || ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() + ) + const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = ownerHasExplicitHost + ? getProjectSetupRuntimeTarget(explicitOwnerHostId) + : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const ownerHostId = ownerHasExplicitHost + ? explicitOwnerHostId + : getRuntimeTargetHostId(ownerTarget) + const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) + const applyRepoUpdate = async () => { + try { + const sanitizedUpdates = sanitizeRepoUpdate(updates) + const target = ownerTarget + const updatedRepo = + target.kind === 'local' + ? await window.api.repos.update({ + repoId: projectId, + updates: sanitizedUpdates, + ...(ownerHasExplicitHost ? { hostId: ownerHostId } : {}) + }) + : ( + await callRuntimeRpc<{ repo: Repo }>( + target, + 'repo.update', + { repo: projectId, updates: sanitizedUpdates }, + { timeoutMs: 15_000 } + ) + ).repo + set((s) => { + const nextRepos = s.repos.map((r) => { + const matchesOwner = ownerHasExplicitHost + ? repoMatchesHostIdentity(r, projectId, ownerHostId) + : repoMatchesHostIdentity(r, projectId, ownerHostId) || r === ownerRepo + if (!matchesOwner) { + return r + } + if (updatedRepo) { + return repoWithFetchedOwner(updatedRepo, target) + } + let mergedRepo: Repo = r + const { + sourceControlAi, + externalWorktreeDiscoverySuppressedAt, + externalWorktreeVisibility, + agentWorktreeVisibility, + ...updatesWithoutClearSentinels + } = sanitizedUpdates + mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels } + if (sourceControlAi === null) { + const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = + mergedRepo + mergedRepo = repoWithoutSourceControlAi + } else if (sourceControlAi !== undefined) { + mergedRepo = { ...mergedRepo, sourceControlAi } + } + if (externalWorktreeVisibility === null) { + const { externalWorktreeVisibility: _visibility, ...repoWithoutVisibility } = + mergedRepo + mergedRepo = { ...repoWithoutVisibility, externalWorktreeVisibilityLegacy: false } + } else if (externalWorktreeVisibility !== undefined) { + mergedRepo = { ...mergedRepo, externalWorktreeVisibility } + } + if (agentWorktreeVisibility === null) { + const { agentWorktreeVisibility: _agentVisibility, ...repoWithoutAgentVisibility } = + mergedRepo + mergedRepo = repoWithoutAgentVisibility + } else if (agentWorktreeVisibility !== undefined) { + mergedRepo = { ...mergedRepo, agentWorktreeVisibility } + } + if (externalWorktreeDiscoverySuppressedAt === null) { + const { + externalWorktreeDiscoverySuppressedAt: _suppressedAt, + ...repoWithoutSuppression + } = mergedRepo + mergedRepo = repoWithoutSuppression + } else if (externalWorktreeDiscoverySuppressedAt !== undefined) { + mergedRepo = { ...mergedRepo, externalWorktreeDiscoverySuppressedAt } + } + return mergedRepo + }) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), + folderWorkspacePathStatuses: {} + } + }) + return true + } catch (err) { + console.error('Failed to update repo:', err) + return false + } + } + const previous = updateRepoChains.get(updateChainKey) + // Why: settings persist as full nested values, so preserve per-repo call order — a slower response mustn't overwrite newer state. + const next = previous + ? previous.catch(() => undefined).then(applyRepoUpdate) + : applyRepoUpdate() + updateRepoChains.set(updateChainKey, next) + const cleanup = () => { + if (updateRepoChains.get(updateChainKey) === next) { + updateRepoChains.delete(updateChainKey) + } + } + void next.then(cleanup, cleanup) + return next + } + } +} diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts new file mode 100644 index 00000000000..80548aff381 --- /dev/null +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -0,0 +1,157 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Repo } from '../../../../shared/repo-types' +import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' +import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' +import { readRuntimeWorktreeVisibilitySnapshot } from '../slices/worktree-visibility-owner-settings' +import { getRepoHostIdentity } from '../slices/repo-host-identity' +import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' +import { getEnvironmentSshStateGeneration } from '../slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '../slices/runtime-status' +import type { RepoSlice } from './repo-state' +import { claimRepoCatalogGeneration, isLatestRepoCatalogGeneration } from './repo-catalog-fencing' +import { + fetchRepoCatalogForTarget, + filterSetupsForPrunedRepoRows, + mergeFetchedRepoCatalog, + projectCompatibilityForReconciledRepos, + reconcileReadoptedSshWorktreeState, + reconcileSupersededSshRepos +} from './repo-catalog-merge' +import { getRuntimeTargetHostId } from '../runtime-target-host' +import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' + +export const runtimeRepoFetchGenerationByEnvironment = new Map() + +export function createRuntimeRepoCatalogActions( + set: Parameters>[0], + get: Parameters>[1] +): Pick { + return { + fetchRuntimeEnvironmentRepos: async (environmentId) => { + const requestGeneration = + (runtimeRepoFetchGenerationByEnvironment.get(environmentId) ?? 0) + 1 + runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration) + const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) + const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) + let catalogGeneration = 0 + set((s) => { + catalogGeneration = s.reposFetchGeneration + 1 + return { reposFetchGeneration: catalogGeneration } + }) + const target = { kind: 'environment' as const, environmentId } + const targetHostId = getRuntimeTargetHostId(target) + claimRepoCatalogGeneration(get, targetHostId, catalogGeneration) + try { + const [catalog, visibilitySnapshot] = await Promise.all([ + fetchRepoCatalogForTarget(target), + readRuntimeWorktreeVisibilitySnapshot(environmentId) + ]) + const visibilityDefaults = visibilitySnapshot.defaults + if ( + runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || + !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || + getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || + getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration + ) { + return [] + } + let finalizedHostRepos: Repo[] = [] + set((s) => { + if ( + runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || + !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || + getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || + getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration + ) { + return s + } + // Why: skip merging a runtime env removed while this Connect-flow fetch was in flight, so purged repos aren't re-added (#8881). + if (isRemovedRuntimeHostId(catalog.hostId, s.removedRuntimeEnvironmentIds)) { + return s + } + const result = mergeFetchedRepoCatalog(catalog, s.repos) + const reconciliation = reconcileSupersededSshRepos(result.repos, s) + const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) + const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(finalizedRepos.map(getRepoHostIdentity)) + const projectCompatibility = projectCompatibilityForReconciledRepos( + finalizedRepos, + catalog.projectHostSetupCompatibility + ) + const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: s.projects, + projectHostSetups: filterSetupsForPrunedRepoRows( + s.projectHostSetups, + result.repos, + finalizedRepos + ) + }, + fetched: projectCompatibility, + repos: finalizedRepos, + hostId: result.hostId + }) + finalizedHostRepos = finalizedRepos.filter( + (repo) => getRepoExecutionHostId(repo) === result.hostId + ) + return { + repos: finalizedRepos, + ...(visibilityDefaults === undefined + ? {} + : { + worktreeVisibilityDefaultsByHost: { + ...s.worktreeVisibilityDefaultsByHost, + [targetHostId]: visibilityDefaults + } + }), + ...(visibilityDefaults !== undefined && s.settings + ? getActiveRuntimeTarget(s.settings).kind === 'environment' && + s.settings.activeRuntimeEnvironmentId === environmentId + ? visibilityDefaults + ? { + settings: { + ...s.settings, + worktreeVisibilityDefaults: visibilityDefaults + }, + worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: environmentId, + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: + visibilitySnapshot.sourceDefaultsSupported ? environmentId : null + } + : { + settings: Object.fromEntries( + Object.entries(s.settings).filter( + ([key]) => key !== 'worktreeVisibilityDefaults' + ) + ) as GlobalSettings, + worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null, + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null + } + : {} + : {}), + pendingSshRepoReadoptions: reconciliation.pendingReadoptions, + ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), + ...mergedProjectCompatibility, + activeRepoId: + s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), + setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( + s.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ) + } + }) + scheduleSafeAutoForkSync(get, finalizedHostRepos) + return finalizedHostRepos + } catch (err) { + console.error(`Failed to fetch repos for runtime environment ${environmentId}:`, err) + return [] + } + } + } +} diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.ts new file mode 100644 index 00000000000..d2167a9296a --- /dev/null +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.ts @@ -0,0 +1,54 @@ +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { settingsForRepoOwner } from './owner-routing' + +export const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000 + +export const safeAutoForkSyncAttempts = new Map< + string, + { attemptedAt: number; promise?: Promise } +>() + +export function getSafeAutoForkSyncKey(repo: Repo): string { + return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` +} + +export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { + for (const repo of repos) { + if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) { + continue + } + const key = getSafeAutoForkSyncKey(repo) + const existingAttempt = safeAutoForkSyncAttempts.get(key) + const now = Date.now() + if ( + existingAttempt?.promise || + (existingAttempt && now - existingAttempt.attemptedAt < SAFE_AUTO_FORK_SYNC_COOLDOWN_MS) + ) { + continue + } + const promise = syncRuntimeGitForkDefaultBranch( + { + settings: settingsForRepoOwner(get(), repo.id), + worktreeId: repo.id, + worktreePath: repo.path, + connectionId: repo.connectionId ?? undefined + }, + repo.upstream + ) + .then(() => undefined) + .catch((error) => { + // Why: safe-auto is opportunistic; auth/protection/divergence failures shouldn't add startup noise (Sync Now handles explicit diagnosis). + console.info('Safe fork auto-sync skipped', error) + }) + .finally(() => { + const current = safeAutoForkSyncAttempts.get(key) + if (current?.promise === promise) { + safeAutoForkSyncAttempts.set(key, { attemptedAt: now }) + } + }) + safeAutoForkSyncAttempts.set(key, { attemptedAt: now, promise }) + } +} diff --git a/src/renderer/src/store/runtime-catalog-hosts.ts b/src/renderer/src/store/runtime-catalog-hosts.ts new file mode 100644 index 00000000000..a149555310f --- /dev/null +++ b/src/renderer/src/store/runtime-catalog-hosts.ts @@ -0,0 +1,8 @@ +export async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> { + try { + return (await window.api.runtimeEnvironments.list()) ?? [] + } catch (err) { + console.warn('Failed to list runtime environments for all-host load:', err) + return [] + } +} diff --git a/src/renderer/src/store/runtime-target-host.ts b/src/renderer/src/store/runtime-target-host.ts new file mode 100644 index 00000000000..5c13e5af3bc --- /dev/null +++ b/src/renderer/src/store/runtime-target-host.ts @@ -0,0 +1,12 @@ +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import type { RuntimeClientTarget } from '../runtime/runtime-rpc-client' + +export function getRuntimeTargetHostId(target: RuntimeClientTarget): ExecutionHostId { + return target.kind === 'environment' + ? toRuntimeExecutionHostId(target.environmentId) + : LOCAL_EXECUTION_HOST_ID +} diff --git a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts index b0298e76732..fb83036ea8d 100644 --- a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts @@ -403,6 +403,36 @@ describe('folder workspace owner-routed mutations', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('deletes only the selected owner row when workspace IDs collide', async () => { + const localFolder = makeFolderWorkspace({ executionHostId: 'local' }) + const runtimeFolder = makeFolderWorkspace({ + name: 'Runtime collision', + folderPath: '/runtime/platform', + executionHostId: 'runtime:env-owner' + }) + folderWorkspacesDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + activeWorktreeId: `folder:${localFolder.id}`, + activeWorkspaceExecutionHostId: 'local', + projectGroups: [ + { ...projectGroup, executionHostId: 'local' }, + { ...projectGroup, executionHostId: 'runtime:env-owner' } + ], + folderWorkspaces: [localFolder, runtimeFolder] + }) + + await expect(store.getState().deleteFolderWorkspace(localFolder.id)).resolves.toBe(true) + + expect(folderWorkspacesDelete).toHaveBeenCalledWith({ + folderWorkspaceId: localFolder.id + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + // Delete is owner-scoped: the sibling host's row keeps the bare ID alive. + expect(store.getState().folderWorkspaces).toEqual([runtimeFolder]) + }) + it('deletes a runtime folder through its owner instead of the focused runtime', async () => { const folderWorkspace = makeFolderWorkspace({ id: 'folder-runtime' }) runtimeEnvironmentCall.mockResolvedValue({ diff --git a/src/renderer/src/store/slices/repos-add-races.test.ts b/src/renderer/src/store/slices/repos-add-races.test.ts new file mode 100644 index 00000000000..363e41e5bad --- /dev/null +++ b/src/renderer/src/store/slices/repos-add-races.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { createTestStore } from './store-test-helpers' +import { + installReposRuntimeRoutingHarness, + localRepo, + reposAdd, + reposList +} from './repos-runtime-routing-fixture' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})) + +installReposRuntimeRoutingHarness() + +describe('repo add/catalog races', () => { + it('does not append a same-host duplicate returned by add', async () => { + reposAdd.mockResolvedValue({ repo: localRepo }) + const existing = { ...localRepo, executionHostId: 'local' as const } + const store = createTestStore() + store.setState({ repos: [existing] }) + + await expect(store.getState().addRepoPath(localRepo.path)).resolves.toEqual(existing) + + expect(store.getState().repos).toEqual([existing]) + }) + + it('appends the same bare ID when add returns a different host identity', async () => { + reposAdd.mockResolvedValue({ repo: localRepo }) + const runtimeSibling: Repo = { + ...localRepo, + path: '/runtime/local-repo', + executionHostId: 'runtime:env-1' + } + const store = createTestStore() + store.setState({ repos: [runtimeSibling] }) + + await store.getState().addRepoPath(localRepo.path) + + expect(store.getState().repos).toEqual([ + runtimeSibling, + { ...localRepo, executionHostId: 'local' } + ]) + }) + + it('lets a catalog resolving after add remove the newly added row', async () => { + const { promise: catalog, resolve: resolveCatalog } = Promise.withResolvers() + reposList.mockReturnValueOnce(catalog) + reposAdd.mockResolvedValue({ repo: localRepo }) + const store = createTestStore() + + const pendingCatalog = store.getState().fetchRepos() + await store.getState().addRepoPath(localRepo.path) + expect(store.getState().repos).toHaveLength(1) + + resolveCatalog([]) + await pendingCatalog + + expect(store.getState().repos).toEqual([]) + }) + + it('keeps an add that completes after the catalog applies', async () => { + const { promise: add, resolve: resolveAdd } = Promise.withResolvers<{ repo: Repo }>() + reposList.mockResolvedValueOnce([]) + reposAdd.mockReturnValueOnce(add) + const store = createTestStore() + + const pendingAdd = store.getState().addRepoPath(localRepo.path) + await store.getState().fetchRepos() + resolveAdd({ repo: localRepo }) + await pendingAdd + + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + }) +}) diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts index 2d89d53ceac..187ede27fe4 100644 --- a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -320,6 +320,147 @@ describe('fetchReposForAllHosts generation', () => { ) }) + it('invalidates folder path statuses when an all-host repo catalog changes', async () => { + reposList.mockResolvedValueOnce([{ ...localRepo, path: '/local/changed' }]) + const store = createTestStore() + store.setState({ + repos: [localRepo], + folderWorkspacePathStatuses: { + cached: { + status: { path: '/local', exists: true }, + checkedAt: 1, + requestSnapshot: 'before-change' + } + } + }) + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + }) + + it('applies a targeted repo catalog that started before a reconnect', async () => { + const { promise: repoList, resolve: resolveRepoList } = Promise.withResolvers() + const { promise: repoListStarted, resolve: markRepoListStarted } = Promise.withResolvers() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRepoListStarted() + return repoList + } + return { + id: `rpc-${args.method}`, + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-before-reconnect' } + } + }) + const store = createTestStore() + store + .getState() + .setRuntimeEnvironments([{ id: 'env-1', createdAt: 1, pairingRevision: 1 } as never]) + + const pending = store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) + await repoListStarted + store + .getState() + .setRuntimeEnvironments([{ id: 'env-1', createdAt: 1, pairingRevision: 2 } as never]) + store.setState({ + repos: [{ ...freshRemoteRepo, executionHostId: 'runtime:env-1' }] + }) + resolveRepoList({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-before-reconnect' } + }) + await pending + + // Current contract: targeted fetchRepos has only catalog-generation fencing. + expect(store.getState().repos[0]?.path).toBe(staleRemoteRepo.path) + }) + + it('applies an all-host repo result that started before a reconnect', async () => { + const { promise: repoList, resolve: resolveRepoList } = Promise.withResolvers() + const { promise: repoListStarted, resolve: markRepoListStarted } = Promise.withResolvers() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRepoListStarted() + return repoList + } + return { + id: `rpc-${args.method}`, + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-before-reconnect' } + } + }) + const store = createTestStore() + store + .getState() + .setRuntimeEnvironments([{ id: 'env-1', createdAt: 1, pairingRevision: 1 } as never]) + + const pending = store.getState().fetchReposForAllHosts() + await repoListStarted + store + .getState() + .setRuntimeEnvironments([{ id: 'env-1', createdAt: 1, pairingRevision: 2 } as never]) + store.setState({ + repos: [localRepo, { ...freshRemoteRepo, executionHostId: 'runtime:env-1' }] + }) + resolveRepoList({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-before-reconnect' } + }) + await pending + + // Current contract: all-host remote legs do not carry the reconnect fence. + expect( + store.getState().repos.find((repo) => repo.executionHostId === 'runtime:env-1')?.path + ).toBe(staleRemoteRepo.path) + }) + + it('drops a Connect-flow catalog and visibility defaults from before a reconnect', async () => { + const { promise: repoList, resolve: resolveRepoList } = Promise.withResolvers() + const { promise: repoListStarted, resolve: markRepoListStarted } = Promise.withResolvers() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRepoListStarted() + return repoList + } + return { + id: `rpc-${args.method}`, + ok: true, + result: + args.method === 'settings.get' + ? { settings: { worktreeVisibilityDefaults: { external: 'show' } } } + : { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-before-reconnect' } + } + }) + const store = createTestStore() + store + .getState() + .setRuntimeEnvironments([{ id: 'env-1', createdAt: 1, pairingRevision: 1 } as never]) + + const pending = store.getState().fetchRuntimeEnvironmentRepos('env-1') + await repoListStarted + store + .getState() + .setRuntimeEnvironments([{ id: 'env-1', createdAt: 1, pairingRevision: 2 } as never]) + resolveRepoList({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-before-reconnect' } + }) + + await expect(pending).resolves.toEqual([]) + expect(store.getState().repos).toEqual([]) + expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeUndefined() + }) + it('does not validate repo UI from a superseded refresh', async () => { let resolveOlderRemote!: (value: unknown) => void let resolveNewerRemote!: (value: unknown) => void diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts index 4cf74a63640..037ed963ec7 100644 --- a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts +++ b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts @@ -2,7 +2,7 @@ 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 type { FolderWorkspacePathStatusCacheEntry } from './repos' +import type { FolderWorkspacePathStatusCacheEntry } from '../repos/repo-state' import { createTestStore } from './store-test-helpers' const projectGroup: ProjectGroup = { diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 1c8cd78be57..9b6bf765d43 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -314,6 +314,8 @@ describe('repo slice host identity routing', () => { const store = createTestStore() store.setState({ repos: [localDuplicate, remoteDuplicate], + activeRepoId: 'same-repo', + filterRepoIds: ['same-repo'], projects: [ { id: 'repo:same-repo', @@ -346,6 +348,9 @@ describe('repo slice host identity routing', () => { await store.getState().removeProject('same-repo') expect(store.getState().repos).toEqual([remoteDuplicate]) + // Current contract: bare-ID UI selections clear even though the sibling host row survives. + expect(store.getState().activeRepoId).toBeNull() + expect(store.getState().filterRepoIds).toEqual([]) expect(store.getState().worktreesByRepo['same-repo']).toEqual([remoteWorktree]) expect(store.getState().tabsByWorktree[localWorktree.id]).toBeUndefined() expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ @@ -361,10 +366,58 @@ describe('repo slice host identity routing', () => { // Why: the id also exists on runtime:env-1, so the local-side removal must be // host-scoped in main to avoid deleting the other host's persisted repo row. expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() expect(ptyKill).toHaveBeenCalledWith('local-pty') expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') }) + it('purges hostless worktree state when two hosts collide on repoId and path', async () => { + const sharedWorktreeId = 'same-repo::/shared' + const localWorktree = makeWorktree({ + id: sharedWorktreeId, + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: sharedWorktreeId, + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [sharedWorktreeId]: [{ id: 'shared-tab', worktreeId: sharedWorktreeId }] as never + }, + lastVisitedAtByWorktreeId: { [sharedWorktreeId]: 10 } + }) + + await store.getState().removeProject('same-repo', { hostId: 'local' }) + + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remoteWorktree]) + // Terminal/editor maps are keyed by hostless worktree ID and are purged, but the + // host-scoped purge deliberately leaves the sibling's legacy bare recency key intact. + expect(store.getState().tabsByWorktree[sharedWorktreeId]).toBeUndefined() + expect(store.getState().lastVisitedAtByWorktreeId[sharedWorktreeId]).toBe(10) + }) + + it('allows two removals of the same owner row to overlap', async () => { + const { promise: firstRemoval, resolve: resolveFirstRemoval } = Promise.withResolvers() + const { promise: secondRemoval, resolve: resolveSecondRemoval } = Promise.withResolvers() + reposRemove.mockReturnValueOnce(firstRemoval).mockReturnValueOnce(secondRemoval) + const store = createTestStore() + store.setState({ repos: [localDuplicate] }) + + const first = store.getState().removeProject(localDuplicate.id) + const second = store.getState().removeProject(localDuplicate.id) + expect(reposRemove).toHaveBeenCalledTimes(2) + + resolveSecondRemoval() + resolveFirstRemoval() + await Promise.all([first, second]) + + expect(store.getState().repos).toEqual([]) + }) it('preserves qualified recency for an exact-id sibling host', async () => { const worktreeId = 'same-repo::/shared/wt' @@ -651,9 +704,59 @@ describe('repo slice host identity routing', () => { }) expect(reposReorderForHost).toHaveBeenCalledWith({ hostId: 'ssh:target', + orderedIds: ['delta', 'charlie'] }) expect(reposReorder).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('treats an occurrence-invalid reorder as a total no-op', async () => { + const store = createTestStore() + store.setState({ repos: [localDuplicate, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate]) + expect(reposReorderForHost).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(uiSet).not.toHaveBeenCalled() + }) + + it('resyncs after a host rejects an optimistic reorder', async () => { + reposReorderForHost.mockResolvedValue({ status: 'rejected' }) + const resync = vi.fn().mockResolvedValue(undefined) + const alpha = { ...localDuplicate, id: 'alpha' } + const bravo = { ...localDuplicate, id: 'bravo' } + const store = createTestStore() + store.setState({ + repos: [alpha, bravo], + fetchReposForAllHosts: resync + }) + + await store.getState().reorderRepos(['bravo', 'alpha']) + + expect(store.getState().repos).toEqual([bravo, alpha]) + expect(resync).toHaveBeenCalledOnce() + }) + + it('resyncs after host persistence throws during an optimistic reorder', async () => { + reposReorderForHost.mockRejectedValue(new Error('persist failed')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const resync = vi.fn().mockResolvedValue(undefined) + const alpha = { ...localDuplicate, id: 'alpha' } + const bravo = { ...localDuplicate, id: 'bravo' } + const store = createTestStore() + store.setState({ + repos: [alpha, bravo], + fetchReposForAllHosts: resync + }) + try { + await store.getState().reorderRepos(['bravo', 'alpha']) + } finally { + errorSpy.mockRestore() + } + + expect(store.getState().repos).toEqual([bravo, alpha]) + expect(resync).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts b/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts new file mode 100644 index 00000000000..e8d0c94e585 --- /dev/null +++ b/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +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 } from './store-test-helpers' + +const { syncFork } = vi.hoisted(() => ({ syncFork: vi.fn() })) + +vi.mock('../../runtime/runtime-git-client', async (importOriginal) => { + const original = await importOriginal>() + return { + ...original, + syncRuntimeGitForkDefaultBranch: syncFork + } +}) + +const runtimeCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + vi.stubGlobal('window', { + api: { + repos: { list: vi.fn() }, + runtimeEnvironments: { + call: (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeCall(args) + } + } + }) +}) + +describe('repo module-lifetime coordinators', () => { + it('lets a newer runtime fetch in another store supersede the older store request', async () => { + const { promise: olderList, resolve: resolveOlderList } = Promise.withResolvers() + let repoListCalls = 0 + runtimeCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'repo.list') { + return { + id: `rpc-${args.method}`, + ok: true, + result: args.method === 'settings.get' ? { settings: {} } : { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime' } + } + } + repoListCalls++ + return repoListCalls === 1 + ? olderList + : { + id: 'rpc-newer', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime' } + } + }) + const firstStore = createTestStore() + const secondStore = createTestStore() + + const older = firstStore.getState().fetchRuntimeEnvironmentRepos('shared-env') + await secondStore.getState().fetchRuntimeEnvironmentRepos('shared-env') + resolveOlderList({ + id: 'rpc-older', + ok: true, + result: { + repos: [ + { + id: 'stale', + path: '/stale', + displayName: 'Stale', + badgeColor: '#000', + addedAt: 1 + } + ] + }, + _meta: { runtimeId: 'runtime' } + }) + + await expect(older).resolves.toEqual([]) + expect(firstStore.getState().repos).toEqual([]) + }) + + it('shares safe-auto fork sync cooldown attempts across store instances', async () => { + const { promise: sync, resolve: resolveSync } = Promise.withResolvers() + syncFork.mockReturnValueOnce(sync) + const safeAutoRepo: Repo = { + id: 'safe-auto-shared', + path: '/safe-auto-shared', + displayName: 'Safe auto', + badgeColor: '#000', + addedAt: 1, + forkSyncMode: 'safe-auto', + upstream: { owner: 'upstream', repo: 'safe-auto-shared' } + } + window.api.repos.list = vi.fn().mockResolvedValue([safeAutoRepo]) + const firstStore = createTestStore() + const secondStore = createTestStore() + + await firstStore.getState().fetchRepos() + await secondStore.getState().fetchRepos() + + expect(syncFork).toHaveBeenCalledTimes(1) + resolveSync() + await sync + }) +}) diff --git a/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts b/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts new file mode 100644 index 00000000000..e6fdd0b82e8 --- /dev/null +++ b/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' + +const importNested = vi.fn() +const listGroups = vi.fn() +const listFolders = vi.fn() +const listRepos = vi.fn() + +const result = { + group: null, + repos: [], + importedCount: 0, + alreadyKnownCount: 0, + failedCount: 0 +} + +beforeEach(() => { + vi.clearAllMocks() + importNested.mockResolvedValue(result) + listGroups.mockResolvedValue([]) + listFolders.mockResolvedValue([]) + listRepos.mockResolvedValue([]) + vi.stubGlobal('window', { + api: { + projectGroups: { importNested, list: listGroups }, + folderWorkspaces: { list: listFolders }, + repos: { list: listRepos } + } + }) +}) + +describe('nested import partial catalog refresh failures', () => { + it.each([ + ['project groups', listGroups], + ['folder workspaces', listFolders], + ['repos', listRepos] + ])('returns the import result when the %s refresh fails', async (_label, failingList) => { + const calls: string[] = [] + listGroups.mockImplementation(async () => { + calls.push('groups') + if (failingList === listGroups) { + throw new Error('groups failed') + } + return [] + }) + listFolders.mockImplementation(async () => { + calls.push('folders') + if (failingList === listFolders) { + throw new Error('folders failed') + } + return [] + }) + listRepos.mockImplementation(async () => { + calls.push('repos') + if (failingList === listRepos) { + throw new Error('repos failed') + } + return [] + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const store = createTestStore() + try { + await expect( + store.getState().importNestedRepos({ + parentPath: '/workspace', + groupName: 'Workspace', + projectPaths: [], + mode: 'group' + }) + ).resolves.toEqual(result) + } finally { + errorSpy.mockRestore() + } + + expect(calls).toEqual(['groups', 'folders', 'repos']) + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts index 4f063f1eba3..8d8093fc8fe 100644 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts @@ -31,6 +31,7 @@ const projectGroup: ProjectGroup = { } const reposRemove = vi.fn() +const reposRemoveForHost = vi.fn() const projectGroupsDelete = vi.fn() const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() @@ -38,6 +39,8 @@ const runtimeEnvironmentTransportCall = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() reposRemove.mockReset() + reposRemoveForHost.mockReset() + reposRemoveForHost.mockResolvedValue(undefined) reposRemove.mockResolvedValue(undefined) projectGroupsDelete.mockReset() runtimeEnvironmentCall.mockReset() @@ -47,7 +50,7 @@ beforeEach(() => { }) vi.stubGlobal('window', { api: { - repos: { remove: reposRemove }, + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, projectGroups: { delete: projectGroupsDelete }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } @@ -193,6 +196,53 @@ describe('project group deletion store routing', () => { expect(store.getState().repos).toEqual([siblingRepo]) }) + it('processes local/direct-SSH same-ID contained rows sequentially', async () => { + const localRepo = { + ...remoteRepo, + id: 'shared', + path: '/local/shared', + projectGroupId: projectGroup.id, + executionHostId: 'local' as const + } + const sshRepo = { + ...localRepo, + path: '/ssh/shared', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' as const + } + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + projectGroups: [{ ...projectGroup, executionHostId: 'local' }], + repos: [localRepo, sshRepo], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { + removeContainedProjects: true + }) + ).resolves.toEqual({ + status: 'deleted-group', + groupId: projectGroup.id, + requestedProjectIds: ['shared', 'shared'], + removedProjectIds: ['shared'], + failedProjectRemovals: [ + { + projectId: 'shared', + reason: 'Project remained in Orca after removeProject completed.' + } + ] + }) + + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'shared', + hostId: 'local' + }) + expect(reposRemove).toHaveBeenCalledWith({ repoId: 'shared' }) + expect(store.getState().repos).toEqual([]) + }) + it('does not remove contained projects when group deletion fails', async () => { projectGroupsDelete.mockResolvedValue(false) const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id } diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts index 667c1d308bb..8ce6455754a 100644 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts @@ -180,6 +180,59 @@ describe('repo slice project host setup lifecycle', () => { }) }) + it('routes duplicate setup IDs through the first row and replaces every collision', async () => { + const localSetup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + displayName: 'Local setup' + } + const updatedLocalSetup = { ...localSetup, displayName: 'Local renamed', updatedAt: 2 } + projectsUpdateHostSetup.mockResolvedValue({ + project, + setup: updatedLocalSetup + }) + const store = createTestStore() + store.setState({ + projectHostSetups: [localSetup, runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await store.getState().updateProjectHostSetup({ + setupId: localSetup.id, + updates: { displayName: 'Local renamed' } + }) + + expect(projectsUpdateHostSetup).toHaveBeenCalledWith({ + setupId: localSetup.id, + updates: { displayName: 'Local renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + // Current contract: setup mutations are keyed by bare setup ID after the first row selects routing. + expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, updatedLocalSetup]) + }) + + it('routes duplicate setup-ID deletion through the first row and removes every collision', async () => { + const localSetup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + displayName: 'Local setup' + } + projectsDeleteHostSetup.mockResolvedValue({ project, setup: localSetup }) + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [localSetup, runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await store.getState().deleteProjectHostSetup({ setupId: localSetup.id }) + + expect(projectsDeleteHostSetup).toHaveBeenCalledWith({ setupId: localSetup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + // Current contract: delete filters the full catalog by bare setup ID. + expect(store.getState().projectHostSetups).toEqual([]) + }) + it('preserves runtime-fetched setup-only states during repo hydration', async () => { const pendingSetup: ProjectHostSetup = { ...runtimeSetup, diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index 0c25f864cec..d3b04b1b078 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -56,7 +56,14 @@ beforeEach(() => { reposList.mockReset() // Only repos.list is exercised here — the missing projects API makes // fetchProjectHostSetupCompatibility fall back to deriving from repos. - vi.stubGlobal('window', { api: { repos: { list: reposList } } }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + remove: vi.fn().mockResolvedValue(undefined) + } + } + }) }) // A repos:changed burst (deleting a project group with contained projects) starts @@ -105,6 +112,23 @@ describe('repos slice stale-fetch race (#7020)', () => { expect(store.getState().repos).toEqual([]) }) + it('reapplies an in-flight catalog after the repo is removed', async () => { + const { promise: catalog, resolve: resolveCatalog } = Promise.withResolvers() + reposList.mockReturnValueOnce(catalog) + const store = createTestStore() + store.setState({ repos: [localRepo] }) + + const pending = store.getState().fetchRepos() + await store.getState().removeProject(localRepo.id) + expect(store.getState().repos).toEqual([]) + + resolveCatalog([localRepo]) + await pending + + // Current contract: removal does not claim a catalog generation, so the latest fetch can resurrect it. + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + }) + it('waits for the superseding catalog fetch before startup hydration', async () => { let resolveStartup!: (repos: Repo[]) => void let resolveRefresh!: (repos: Repo[]) => void diff --git a/src/renderer/src/store/slices/repos-update-serialization.test.ts b/src/renderer/src/store/slices/repos-update-serialization.test.ts index ebfc23161d5..deabe87bda3 100644 --- a/src/renderer/src/store/slices/repos-update-serialization.test.ts +++ b/src/renderer/src/store/slices/repos-update-serialization.test.ts @@ -108,6 +108,48 @@ describe('repo update serialization', () => { ) }) + it('runs same-ID updates on different hosts independently', async () => { + const slowLocalUpdate = deferred() + reposUpdate.mockImplementationOnce(() => slowLocalUpdate.promise) + reposUpdate.mockResolvedValueOnce(undefined) + const sshRepo: Repo = { + ...localRepo, + path: '/ssh/repo', + displayName: 'SSH', + executionHostId: 'ssh:connection-1' + } + const store = createTestStore() + store.setState({ repos: [localRepo, sshRepo] }) + + const local = store + .getState() + .updateRepo(localRepo.id, { displayName: 'Local slow' }, { hostId: 'local' }) + const ssh = store + .getState() + .updateRepo(sshRepo.id, { displayName: 'SSH fast' }, { hostId: 'ssh:connection-1' }) + + expect(reposUpdate).toHaveBeenCalledTimes(2) + await ssh + expect(store.getState().repos).toEqual([localRepo, { ...sshRepo, displayName: 'SSH fast' }]) + + slowLocalUpdate.resolve() + await local + expect(store.getState().repos).toEqual([ + { ...localRepo, displayName: 'Local slow' }, + { ...sshRepo, displayName: 'SSH fast' } + ]) + expect(reposUpdate).toHaveBeenNthCalledWith(1, { + repoId: localRepo.id, + updates: { displayName: 'Local slow' }, + hostId: 'local' + }) + expect(reposUpdate).toHaveBeenNthCalledWith(2, { + repoId: sshRepo.id, + updates: { displayName: 'SSH fast' }, + hostId: 'ssh:connection-1' + }) + }) + it('continues a repo update chain after a failed update', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { @@ -128,6 +170,43 @@ describe('repo update serialization', () => { } }) + it('clears nullable fallback fields when local IPC returns no authoritative repo', async () => { + reposUpdate.mockResolvedValueOnce(undefined) + const repo: Repo = { + ...localRepo, + externalWorktreeVisibility: 'show', + agentWorktreeVisibility: 'hide', + sourceControlAi: {}, + externalWorktreeDiscoverySuppressedAt: 123 + } + const store = createTestStore() + store.setState({ repos: [repo] }) + + await store.getState().updateRepo(repo.id, { + externalWorktreeVisibility: null, + agentWorktreeVisibility: null, + sourceControlAi: null, + externalWorktreeDiscoverySuppressedAt: null + }) + + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: repo.id, + updates: { + externalWorktreeVisibility: null, + agentWorktreeVisibility: null, + sourceControlAi: null, + externalWorktreeDiscoverySuppressedAt: null + } + }) + expect(store.getState().repos[0]).toMatchObject({ + externalWorktreeVisibilityLegacy: false + }) + expect(store.getState().repos[0]?.externalWorktreeVisibility).toBeUndefined() + expect(store.getState().repos[0]?.agentWorktreeVisibility).toBeUndefined() + expect(store.getState().repos[0]?.sourceControlAi).toBeUndefined() + expect(store.getState().repos[0]?.externalWorktreeDiscoverySuppressedAt).toBeUndefined() + }) + it('does not apply repo icons that fail shared sanitization', async () => { reposUpdate.mockResolvedValueOnce(undefined) const store = createTestStore() diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 56bd44159e6..f5281146ee9 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1,4108 +1,86 @@ -/* eslint-disable max-lines -- Why: repo slice owns local/runtime routing, add/remove/reorder side effects, and cross-slice teardown; splitting mid-refactor would obscure its invariants. */ import type { StateCreator } from 'zustand' -import { toast } from 'sonner' import type { AppState } from '../types' -import type { SshRepoReadoption } from '../../../../shared/ssh-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { - NestedRepoScanResult, - ProjectGroup, - ProjectGroupImportResult -} from '../../../../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCloneArgs, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupExistingFolderArgs, - ProjectHostSetupResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs -} from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { - getProjectIdentityKey, - mergeCatalogCreatedAt, - mergeCatalogUpdatedAt, - projectHostSetupProjectionFromRepos, - type ProjectHostSetupProjection -} from '../../../../shared/project-host-setup-projection' -import { - FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, - PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, - WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' -import { - FOLDER_WORKSPACE_PATH_STATUS_TTL_MS, - type FolderWorkspacePathStatus, - type FolderWorkspacePathStatusRequest -} from '../../../../shared/folder-workspace-path-status' -import { isGitRepoKind } from '../../../../shared/repo-kind' -import { sanitizeRepoIcon } from '../../../../shared/repo-icon' -import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/manual-repo-order' -import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' -import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' -import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' -import { structuralValuesEqual } from '../../../../shared/structural-value-equality' -import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' -import { - catalogOwnsHost, - getProjectGroupHostId, - projectGroupMatchesOwnerHost, - resolveProjectGroupOwnerHostId, - settingsForProjectGroupOwner -} from './project-group-owner-routing' -import { reconcileCatalogRows, reconcileFetchedRepos } from './repo-identity-reconcile' -import { retainValidFilterRepoIds } from './repo-filter-selection' -import { readRuntimeWorktreeVisibilitySnapshot } from './worktree-visibility-owner-settings' -import { - mergeSshRepoReadoptions, - reconcileReadoptedSshRepoRows, - type SshRepoReconciliation -} from './superseded-ssh-repo-rows' -import { reconcileReadoptedSshWorktreesByRepo } from './readopted-ssh-worktree-rows' -import { splitRepoReorderByHost } from './repo-reorder-host-split' -import { omitSparsePresetsForRepos } from './sparse-presets' -import { - findRepoForHost, - getRepoHostIdentity, - getRepoHostIdentityForParts, - repoMatchesHostIdentity -} from './repo-host-identity' -import { - assertRuntimeEnvironmentCapability, - callRuntimeRpc, - getActiveRuntimeTarget, - hasRuntimeRpcErrorCode, - settingsForRuntimeOwner -} from '../../runtime/runtime-rpc-client' -import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' -import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' -import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' -import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' -import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' -import { translate } from '@/i18n/i18n' -import { - getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toRuntimeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { isRemovedRuntimeHostId } from './stale-runtime-host-rows' -import { - normalizeCustomWorktreeVisibilitySources, - normalizeWorktreeVisibilitySourcePreferences -} from '../../../../shared/worktree/visibility-sources' -import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' -import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh' -import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' -import { - findFolderWorkspaceOwner, - getExecutionHostIdForFolderWorkspace, - getRuntimeEnvironmentIdForFolderWorkspace -} from '@/lib/folder-workspace-runtime-owner' -import { - FolderWorkspaceUpdateCoordinator, - type FolderWorkspaceUpdateTicket -} from './folder-workspace-update-coordinator' +import type { RepoSlice } from '../repos/repo-state' +import { createRepoCatalogActions } from '../repos/repo-catalog-actions' +import { createRuntimeRepoCatalogActions } from '../repos/runtime-repo-catalog-actions' +import { createAllHostRepoCatalogActions } from '../repos/all-host-repo-catalog-actions' +import { createProjectGroupCatalogActions } from '../project-groups/project-group-catalog-actions' +import { createFolderWorkspaceCatalogActions } from '../folder-workspaces/folder-workspace-catalog-actions' +import { createFolderPathStatusActions } from '../folder-workspaces/folder-path-status-actions' +import { createNestedRepositoryActions } from '../project-groups/nested-repository-operations' +import { createProjectGroupMutationActions } from '../project-groups/project-group-mutations' +import { createFolderWorkspaceMutationActions } from '../folder-workspaces/folder-workspace-mutations' +import { createRepoAddActions } from '../repos/repo-add-actions' +import { createProjectHostSetupActions } from '../projects/project-host-setup-actions' +import { createRepoRemovalActions } from '../repos/repo-removal' +import { createProjectUpdateActions } from '../projects/project-update' +import { createRepoUpdateActions } from '../repos/repo-update' +import { createRepoOrderingActions } from '../repos/repo-ordering' -const ERROR_TOAST_DURATION = 60_000 -const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000 -const safeAutoForkSyncAttempts = new Map }>() -const runtimeRepoFetchGenerationByEnvironment = new Map() -type HostCatalogKind = 'project-groups' | 'folder-workspaces' -type HostCatalogFence = { - key: string - generation: number - target: ReturnType - sshStateGeneration: number | null - runtimeConnectionGeneration: number | null -} - -export type RepoUpdate = Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'forkSyncMode' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - > -> & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null -} - -type ProjectUpdate = ProjectUpdateArgs['updates'] - -type FolderWorkspaceUpdates = Partial< - Pick< - FolderWorkspace, - | 'name' - | 'folderPath' - | 'linkedTask' - | 'linkedTaskSourceContext' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'lastActivityAt' - | 'diffComments' - > -> - -type FolderWorkspaceUpdateField = keyof FolderWorkspaceUpdates -type FolderWorkspaceUpdateCoordinatorInstance = - FolderWorkspaceUpdateCoordinator -type RepoSliceGet = Parameters>[1] - -const folderWorkspaceUpdateCoordinators = new WeakMap< - RepoSliceGet, - FolderWorkspaceUpdateCoordinatorInstance ->() - -function getFolderWorkspaceUpdateCoordinator( - get: RepoSliceGet -): FolderWorkspaceUpdateCoordinatorInstance { - const existing = folderWorkspaceUpdateCoordinators.get(get) - if (existing) { - return existing - } - const created = new FolderWorkspaceUpdateCoordinator() - folderWorkspaceUpdateCoordinators.set(get, created) - return created -} - -type NestedRepoScanControls = { - scanId?: string - onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null -} - -type NestedRepoScanCancelOptions = { - runtimeEnvironmentId?: string | null -} - -export type FolderWorkspacePathStatusCacheEntry = { - status: FolderWorkspacePathStatus - checkedAt: number - requestSnapshot: string -} - -export type DeleteProjectGroupWithContainedProjectsOptions = { - removeContainedProjects: boolean - // hostId disambiguates which host's group row to delete when the id exists on multiple hosts. - hostId?: ExecutionHostId -} - -type AllHostCatalogFetchOptions = { - remoteHosts?: 'include' | 'skip' -} - -export type ProjectRemovalFailure = { - projectId: string - reason: string -} - -export type DeleteProjectGroupWithContainedProjectsResult = - | { - status: 'deleted-group' - groupId: string - requestedProjectIds: string[] - removedProjectIds: string[] - failedProjectRemovals: ProjectRemovalFailure[] - } - | { - status: 'missing-group' | 'group-delete-failed' - groupId: string - requestedProjectIds: string[] - removedProjectIds: [] - failedProjectRemovals: [] - } - -function normalizeNestedRepoScanResult(scan: NestedRepoScanResult): NestedRepoScanResult { +export const createRepoSlice: StateCreator = (set, get) => { + const repoCatalogActions = createRepoCatalogActions(set, get) + const runtimeRepoCatalogActions = createRuntimeRepoCatalogActions(set, get) + const allHostRepoCatalogActions = createAllHostRepoCatalogActions(set, get) + const projectGroupCatalogActions = createProjectGroupCatalogActions(set, get) + const folderWorkspaceCatalogActions = createFolderWorkspaceCatalogActions(set, get) + const folderPathStatusActions = createFolderPathStatusActions(set, get) + const nestedRepositoryOperations = createNestedRepositoryActions(set, get) + const projectGroupMutations = createProjectGroupMutationActions(set, get) + const folderWorkspaceMutations = createFolderWorkspaceMutationActions(set, get) + const repoAddActions = createRepoAddActions(set, get) + const projectHostSetupActions = createProjectHostSetupActions(set, get) + const repoRemoval = createRepoRemovalActions(set, get) + const projectUpdate = createProjectUpdateActions(set, get) + const repoUpdate = createRepoUpdateActions(set, get) + const repoOrdering = createRepoOrderingActions(set, get) return { - ...scan, - stopped: scan.stopped ?? false, - maxDepth: scan.maxDepth ?? 3, - maxRepos: scan.maxRepos ?? 100, - timeoutMs: scan.timeoutMs ?? null + repos: [], + projects: [], + projectHostSetups: [], + projectGroups: [], + folderWorkspaces: [], + folderWorkspacePathStatuses: {}, + activeRepoId: null, + reposFetchGeneration: 0, + pendingSshRepoReadoptions: [], + recordSshRepoReadoptions: repoCatalogActions.recordSshRepoReadoptions, + fetchRepos: repoCatalogActions.fetchRepos, + fetchRuntimeEnvironmentRepos: runtimeRepoCatalogActions.fetchRuntimeEnvironmentRepos, + fetchReposForAllHosts: allHostRepoCatalogActions.fetchReposForAllHosts, + awaitLocalRepoCatalogSettlement: repoCatalogActions.awaitLocalRepoCatalogSettlement, + fetchProjectGroups: projectGroupCatalogActions.fetchProjectGroups, + fetchProjectGroupsForAllHosts: projectGroupCatalogActions.fetchProjectGroupsForAllHosts, + fetchFolderWorkspaces: folderWorkspaceCatalogActions.fetchFolderWorkspaces, + fetchFolderWorkspacesForAllHosts: + folderWorkspaceCatalogActions.fetchFolderWorkspacesForAllHosts, + getFolderWorkspacePathStatusCacheKey: + folderPathStatusActions.getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus: folderPathStatusActions.getFreshFolderWorkspacePathStatus, + fetchFolderWorkspacePathStatus: folderPathStatusActions.fetchFolderWorkspacePathStatus, + scanNestedRepos: nestedRepositoryOperations.scanNestedRepos, + cancelNestedRepoScan: nestedRepositoryOperations.cancelNestedRepoScan, + importNestedRepos: nestedRepositoryOperations.importNestedRepos, + createProjectGroup: projectGroupMutations.createProjectGroup, + createFolderWorkspace: folderWorkspaceMutations.createFolderWorkspace, + updateFolderWorkspace: folderWorkspaceMutations.updateFolderWorkspace, + deleteFolderWorkspace: folderWorkspaceMutations.deleteFolderWorkspace, + updateProjectGroup: projectGroupMutations.updateProjectGroup, + deleteProjectGroup: projectGroupMutations.deleteProjectGroup, + deleteProjectGroupWithContainedProjects: + projectGroupMutations.deleteProjectGroupWithContainedProjects, + moveProjectToGroup: projectGroupMutations.moveProjectToGroup, + addRepoPath: repoAddActions.addRepoPath, + setupProjectExistingFolder: projectHostSetupActions.setupProjectExistingFolder, + createProjectHostSetup: projectHostSetupActions.createProjectHostSetup, + updateProjectHostSetup: projectHostSetupActions.updateProjectHostSetup, + deleteProjectHostSetup: projectHostSetupActions.deleteProjectHostSetup, + setupProjectClone: projectHostSetupActions.setupProjectClone, + addRepo: repoAddActions.addRepo, + addNonGitFolder: repoAddActions.addNonGitFolder, + removeProject: repoRemoval.removeProject, + updateProject: projectUpdate.updateProject, + updateRepo: repoUpdate.updateRepo, + setActiveRepo: repoOrdering.setActiveRepo, + reorderRepos: repoOrdering.reorderRepos } } - -function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { - const sanitized = { ...updates } - if ('badgeColor' in sanitized) { - const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) - if (!badgeColor) { - delete sanitized.badgeColor - } else { - sanitized.badgeColor = badgeColor - } - } - if ('repoIcon' in sanitized) { - const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) - if (repoIcon === undefined) { - delete sanitized.repoIcon - } else { - sanitized.repoIcon = repoIcon - } - } - if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) { - sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined - } - if ( - 'forkSyncMode' in sanitized && - sanitized.forkSyncMode !== undefined && - sanitized.forkSyncMode !== 'ask' && - sanitized.forkSyncMode !== 'safe-auto' && - sanitized.forkSyncMode !== 'off' - ) { - delete sanitized.forkSyncMode - } - if ('customWorktreeVisibilitySources' in sanitized) { - const sources = normalizeCustomWorktreeVisibilitySources( - sanitized.customWorktreeVisibilitySources - ) - if (!sources) { - delete sanitized.customWorktreeVisibilitySources - } else { - sanitized.customWorktreeVisibilitySources = sources - } - } - if ('worktreeVisibilitySourcePreferences' in sanitized) { - const preferences = normalizeWorktreeVisibilitySourcePreferences( - sanitized.worktreeVisibilitySourcePreferences - ) - if (!preferences) { - delete sanitized.worktreeVisibilitySourcePreferences - } else { - sanitized.worktreeVisibilitySourcePreferences = preferences - } - } - return sanitized -} - -const updateRepoChainsByStore = new WeakMap<() => AppState, Map>>() - -function getRepoUpdateChains(get: () => AppState): Map> { - let chains = updateRepoChainsByStore.get(get) - if (!chains) { - chains = new Map>() - updateRepoChainsByStore.set(get, chains) - } - return chains -} - -function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean { - return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId -} - -function getKnownRepoWorktreeIds(state: AppState, projectId: string, hostId?: string): string[] { - const ids = new Set() - for (const worktree of state.worktreesByRepo[projectId] ?? []) { - if (!hostId || worktreeBelongsToHost(worktree, hostId)) { - ids.add(worktree.id) - } - } - for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) { - if (!hostId || worktreeBelongsToHost(worktree, hostId)) { - ids.add(worktree.id) - } - } - return [...ids] -} - -function getRuntimeTargetHostId( - target: ReturnType -): ReturnType | typeof LOCAL_EXECUTION_HOST_ID { - return target.kind === 'environment' - ? toRuntimeExecutionHostId(target.environmentId) - : LOCAL_EXECUTION_HOST_ID -} - -function getProjectSetupRuntimeTarget( - hostId: ProjectHostSetupExistingFolderArgs['hostId'] -): ReturnType { - const parsedHost = parseExecutionHostId(hostId) - return parsedHost?.kind === 'runtime' - ? { kind: 'environment', environmentId: parsedHost.environmentId } - : { kind: 'local' } -} - -function getProjectUpdateRuntimeTarget( - state: AppState, - projectId: string -): ReturnType { - const target = getActiveRuntimeTarget(state.settings) - if (target.kind !== 'environment') { - return target - } - const runtimeHostId = getRuntimeTargetHostId(target) - return state.projectHostSetups.some( - (setup) => setup.projectId === projectId && setup.hostId === runtimeHostId - ) - ? target - : { kind: 'local' } -} - -function getSafeAutoForkSyncKey(repo: Repo): string { - return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` -} - -function formatProjectPresenceProfileNames(profileNames: readonly string[]): string { - const names = [...new Set(profileNames.map((name) => name.trim()).filter(Boolean))] - if (names.length <= 3) { - return names.join(', ') - } - // Why: the "+N more" overflow suffix is user-visible toast copy and must localize. - return translate('auto.store.slices.repos.presenceProfileOverflow', '{{names}} +{{count}} more', { - names: names.slice(0, 3).join(', '), - count: names.length - 3 - }) -} - -async function warnIfProjectKnownInAnotherProfile( - repo: Repo, - activeOrcaProfileId: string | null -): Promise { - const findProjectProfiles = window.api.orcaProfiles?.findProjectProfiles - // Why: without an active profile ID the scan can't exclude the current profile and would false-positive on the just-added project. - if (!findProjectProfiles || !activeOrcaProfileId) { - return - } - try { - const result = await findProjectProfiles({ - path: repo.path, - connectionId: repo.connectionId ?? null, - executionHostId: getRepoExecutionHostId(repo), - excludeProfileId: activeOrcaProfileId - }) - const description = formatProjectPresenceProfileNames( - result.projects.map((project) => project.profileName) - ) - if (!description) { - return - } - toast.warning( - translate('auto.store.slices.repos.2dcd706774', 'Project also exists in another profile'), - { description } - ) - } catch (err) { - // Why: adding a project should not fail because an advisory profile scan failed. - console.warn('Failed to check project presence in other profiles:', err) - } -} - -function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { - for (const repo of repos) { - if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) { - continue - } - const key = getSafeAutoForkSyncKey(repo) - const existingAttempt = safeAutoForkSyncAttempts.get(key) - const now = Date.now() - if ( - existingAttempt?.promise || - (existingAttempt && now - existingAttempt.attemptedAt < SAFE_AUTO_FORK_SYNC_COOLDOWN_MS) - ) { - continue - } - const promise = syncRuntimeGitForkDefaultBranch( - { - settings: settingsForRepoOwner(get(), repo.id), - worktreeId: repo.id, - worktreePath: repo.path, - connectionId: repo.connectionId ?? undefined - }, - repo.upstream - ) - .then(() => undefined) - .catch((error) => { - // Why: safe-auto is opportunistic; auth/protection/divergence failures shouldn't add startup noise (Sync Now handles explicit diagnosis). - console.info('Safe fork auto-sync skipped', error) - }) - .finally(() => { - const current = safeAutoForkSyncAttempts.get(key) - if (current?.promise === promise) { - safeAutoForkSyncAttempts.set(key, { attemptedAt: now }) - } - }) - safeAutoForkSyncAttempts.set(key, { attemptedAt: now, promise }) - } -} - -function repoWithFetchedOwner(repo: Repo, target: ReturnType): Repo { - if (target.kind === 'environment') { - return { ...repo, executionHostId: getRuntimeTargetHostId(target) } - } - if (repo.connectionId) { - return { ...repo, executionHostId: getRepoExecutionHostId(repo) } - } - return repo.executionHostId ? repo : { ...repo, executionHostId: LOCAL_EXECUTION_HOST_ID } -} - -function projectGroupWithFetchedOwner( - projectGroup: ProjectGroup, - target: ReturnType -): ProjectGroup { - if (target.kind === 'environment') { - return { ...projectGroup, executionHostId: getRuntimeTargetHostId(target) } - } - if (projectGroup.connectionId) { - return { ...projectGroup, executionHostId: toSshExecutionHostId(projectGroup.connectionId) } - } - return { ...projectGroup, executionHostId: LOCAL_EXECUTION_HOST_ID } -} - -function setupWithFetchedOwner( - setup: ProjectHostSetup, - target: ReturnType -): ProjectHostSetup { - const hostId = getRuntimeTargetHostId(target) - if (target.kind !== 'environment') { - return setup - } - const executionHostId = setup.executionHostId ?? setup.hostId - return { - ...setup, - hostId, - executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, - runtimeOwnerEnvironmentId: target.environmentId, - // Why: paired clients route through the HUB and must not treat its private SSH target as client-local configuration. - connectionId: null - } -} - -async function fetchProjectHostSetupCompatibility( - target: ReturnType, - repos: readonly Repo[] -): Promise { - try { - if (target.kind === 'local') { - const projectsApi = ( - window.api as typeof window.api & { - projects?: { - list?: () => Promise - listHostSetups?: () => Promise - } - } - ).projects - if (!projectsApi?.list || !projectsApi.listHostSetups) { - throw new Error('projects_api_unavailable') - } - return { - projects: await projectsApi.list(), - setups: await projectsApi.listHostSetups() - } - } - await assertProjectHostSetupRuntimeCapability(target) - const [projectResponse, setupResponse] = await Promise.all([ - callRuntimeRpc<{ projects: Project[] }>(target, 'project.list', undefined, { - timeoutMs: 15_000 - }), - callRuntimeRpc<{ setups: ProjectHostSetup[] }>(target, 'projectHostSetup.list', undefined, { - timeoutMs: 15_000 - }) - ]) - return { - projects: projectResponse.projects, - setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) - } - } catch { - // Why: newer clients must hydrate against older runtimes that only know repo.list; derive the transitional model locally. - return projectHostSetupProjectionFromRepos(repos) - } -} - -async function assertProjectHostSetupRuntimeCapability( - target: ReturnType -): Promise { - if (target.kind !== 'environment') { - return - } - await assertRuntimeEnvironmentCapability( - target.environmentId, - PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, - 'The selected Orca server does not support project host setup yet. Update Orca on the server and try again.', - 15_000 - ) -} - -async function assertProjectHostSetupMutationRuntimeCapabilities( - target: ReturnType -): Promise { - if (target.kind !== 'environment') { - return - } - await assertProjectHostSetupRuntimeCapability(target) - await assertRuntimeEnvironmentCapability( - target.environmentId, - WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, - 'The selected Orca server does not support explicit workspace run hosts yet. Update Orca on the server and try again.', - 15_000 - ) -} - -function projectCompatibilityFromRepos( - repos: readonly Repo[] -): Pick { - const projection = projectHostSetupProjectionFromRepos(repos) - return { - projects: projection.projects, - projectHostSetups: projection.setups - } -} - -function mergeProjectCompatibilityProject(base: Project, overlay: Project): Project { - const localWindowsRuntimePreference = - 'localWindowsRuntimePreference' in overlay - ? overlay.localWindowsRuntimePreference - : base.localWindowsRuntimePreference - const project: Project = { - ...base, - ...overlay, - // Why: all-host startup fetches hosts separately; one host's record must not erase repo ownership learned from another host with the same id. - sourceRepoIds: [...new Set([...base.sourceRepoIds, ...overlay.sourceRepoIds])], - // Why not plain min/max: a host whose repos carry no `addedAt` projects 0, and 0 means - // unknown, not epoch — it must not win over the real timestamp the other host knows. - createdAt: mergeCatalogCreatedAt(base.createdAt, overlay.createdAt), - updatedAt: mergeCatalogUpdatedAt(base.updatedAt, overlay.updatedAt) - } - if (localWindowsRuntimePreference === undefined) { - delete project.localWindowsRuntimePreference - } else { - project.localWindowsRuntimePreference = localWindowsRuntimePreference - } - return project -} - -function mergeProjectCompatibilityProjects( - base: readonly Project[], - overlay: readonly Project[] -): Project[] { - const merged = [...base] - const indexById = new Map(merged.map((entry, index) => [entry.id, index])) - for (const entry of overlay) { - const index = indexById.get(entry.id) - if (index === undefined) { - indexById.set(entry.id, merged.length) - merged.push(entry) - } else { - merged[index] = mergeProjectCompatibilityProject(merged[index]!, entry) - } - } - return merged -} - -function mergeUpdatedProjectCompatibilityProject( - base: Project, - updated: Project, - updates: ProjectUpdate -): Project { - const project = mergeProjectCompatibilityProject(base, updated) - if ('localWindowsRuntimePreference' in updates) { - const localWindowsRuntimePreference = - 'localWindowsRuntimePreference' in updated - ? updated.localWindowsRuntimePreference - : updates.localWindowsRuntimePreference - // Why: project.update returns one host's record, but preference clears must override the cross-host metadata-preservation merge. - if (localWindowsRuntimePreference === undefined) { - delete project.localWindowsRuntimePreference - } else { - project.localWindowsRuntimePreference = localWindowsRuntimePreference - } - } - return project -} - -function getCurrentSourceRepoIds(project: Project, currentRepoIds: ReadonlySet): string[] { - return project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) -} - -function getReposById(repos: readonly Repo[]): Map { - const reposById = new Map() - for (const repo of repos) { - const existing = reposById.get(repo.id) - if (existing) { - existing.push(repo) - } else { - reposById.set(repo.id, [repo]) - } - } - return reposById -} - -function getSourceRepoIdsOutsideHost( - project: Project, - reposById: ReadonlyMap, - hostId: string -): string[] { - return project.sourceRepoIds.filter((repoId) => { - const repos = reposById.get(repoId) ?? [] - return repos.some((repo) => getRepoExecutionHostId(repo) !== hostId) - }) -} - -function getMergedSourceRepoIdsForHostRefresh( - previous: Project, - current: Project, - reposById: ReadonlyMap, - hostId: string -): string[] { - // Why: current-first keeps the order independent of which host refreshed. Prefixing the - // cross-host remainder made it a function of hostId, so a cross-host project's ids oscillated - // between refreshes and the projects reconcile could never reuse the row. - return [ - ...new Set([ - ...getCurrentSourceRepoIds(current, new Set(reposById.keys())), - ...getSourceRepoIdsOutsideHost(previous, reposById, hostId) - ]) - ] -} - -function projectWithCurrentSourceRepoIds( - project: Project, - currentRepoIds: ReadonlySet -): Project { - const sourceRepoIds = getCurrentSourceRepoIds(project, currentRepoIds) - return sourceRepoIds.length === project.sourceRepoIds.length - ? project - : { ...project, sourceRepoIds } -} - -function getLocalHostRepoBadgeColor( - project: Project, - reposById: ReadonlyMap -): string | null { - for (const repoId of project.sourceRepoIds) { - for (const repo of reposById.get(repoId) ?? []) { - if (getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) { - return repo.badgeColor - } - } - } - return null -} - -function mergePreviousProjectMetadata( - previous: Project, - current: Project, - reposById: ReadonlyMap, - hostId: string -): Project { - const project = mergeProjectCompatibilityProject(previous, current) - const sourceRepoIds = getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) - const localBadgeColor = getLocalHostRepoBadgeColor({ ...project, sourceRepoIds }, reposById) - if (localBadgeColor !== null) { - // Why: badge color is per-host repo metadata; a remote host sharing the project must not repaint the color the user chose locally. - project.badgeColor = localBadgeColor - } - if (hostId === LOCAL_EXECUTION_HOST_ID) { - // Why: localWindowsRuntimePreference belongs to the local host; a local refresh that omits it is authoritative and clears stale renderer state. - if ('localWindowsRuntimePreference' in current) { - if (current.localWindowsRuntimePreference === undefined) { - delete project.localWindowsRuntimePreference - } else { - project.localWindowsRuntimePreference = current.localWindowsRuntimePreference - } - } else { - delete project.localWindowsRuntimePreference - } - } else if (previous.localWindowsRuntimePreference !== undefined) { - // Why: a remote runtime's local Windows preference must not overwrite the client-local project runtime setting. - project.localWindowsRuntimePreference = previous.localWindowsRuntimePreference - } - return { - ...project, - // Why: fetched project metadata can lag repo.list; track ownership to the reconciled repos so removed-host repos don't linger. - sourceRepoIds - } -} - -function mergeProjectHostSetupCompatibility( - derived: Pick, - fetched: ProjectHostSetupProjection -): Pick { - const fetchedRepoSetupKeys = new Set(fetched.setups.map(getRepoDerivedSetupKey)) - const derivedSetups = derived.projectHostSetups.filter( - (setup) => !fetchedRepoSetupKeys.has(getRepoDerivedSetupKey(setup)) - ) - const projectHostSetups = mergeProjectHostSetupsByOwner(derivedSetups, fetched.setups) - const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) - const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) - return { - projects: mergeProjectCompatibilityProjects(derived.projects, fetched.projects).filter( - (project) => fetchedProjectIds.has(project.id) || setupProjectIds.has(project.id) - ), - projectHostSetups - } -} - -function getRepoDerivedSetupKey(setup: ProjectHostSetup): string { - // Why: authoritative routing provenance may be absent from the repo-derived fallback it replaces. - return JSON.stringify([setup.hostId, setup.repoId || setup.id]) -} - -function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { - return JSON.stringify([ - setup.hostId, - setup.executionHostId ?? setup.hostId, - setup.runtimeOwnerEnvironmentId ?? null, - setup.repoId || setup.id - ]) -} - -function mergeProjectHostSetupsByOwner( - base: readonly ProjectHostSetup[], - overlay: readonly ProjectHostSetup[] -): ProjectHostSetup[] { - const merged = [...base] - const indexByOwner = new Map( - merged.map((entry, index) => [getProjectHostSetupOwnerKey(entry), index]) - ) - for (const entry of overlay) { - const index = indexByOwner.get(getProjectHostSetupOwnerKey(entry)) - if (index === undefined) { - indexByOwner.set(getProjectHostSetupOwnerKey(entry), merged.length) - merged.push(entry) - } else { - merged[index] = entry - } - } - return merged -} - -function getProjectHostIds( - project: Project, - setups: readonly ProjectHostSetup[], - repos: readonly Repo[] -): Set { - const hostIds = getExplicitProjectHostIds(project, setups, repos) - if (hostIds.size === 0) { - hostIds.add(LOCAL_EXECUTION_HOST_ID) - } - return hostIds -} - -function getExplicitProjectHostIds( - project: Project, - setups: readonly ProjectHostSetup[], - repos: readonly Repo[] -): Set { - const hostIds = new Set() - const sourceRepoIds = new Set(project.sourceRepoIds) - for (const setup of setups) { - if (setup.projectId === project.id) { - hostIds.add(setup.hostId) - } - } - for (const repo of repos) { - if (sourceRepoIds.has(repo.id)) { - hostIds.add(getRepoExecutionHostId(repo)) - } - } - return hostIds -} - -function indexProjectHostSetupsByProjectId( - setups: readonly ProjectHostSetup[] -): Map { - const setupsByProjectId = new Map() - for (const setup of setups) { - const existing = setupsByProjectId.get(setup.projectId) - if (existing) { - existing.push(setup) - } else { - setupsByProjectId.set(setup.projectId, [setup]) - } - } - return setupsByProjectId -} - -function getProjectSourceRepos( - project: Project, - reposById: ReadonlyMap -): Repo[] { - const sourceRepos: Repo[] = [] - for (const repoId of project.sourceRepoIds) { - for (const repo of reposById.get(repoId) ?? []) { - sourceRepos.push(repo) - } - } - return sourceRepos -} - -// Why: the host-id resolvers rescan every setup and repo per project; feeding them the project's own slices keeps a catalog refresh linear. -function createProjectHostIdIndex( - setups: readonly ProjectHostSetup[], - reposById: ReadonlyMap, - resolveHostIds: ( - project: Project, - setups: readonly ProjectHostSetup[], - repos: readonly Repo[] - ) => Set -): (project: Project) => ReadonlySet { - const noSetups: readonly ProjectHostSetup[] = [] - const hostIdsByProject = new Map>() - let setupsByProjectId: Map | null = null - return (project) => { - const cached = hostIdsByProject.get(project) - if (cached) { - return cached - } - setupsByProjectId ??= indexProjectHostSetupsByProjectId(setups) - const hostIds = resolveHostIds( - project, - setupsByProjectId.get(project.id) ?? noSetups, - getProjectSourceRepos(project, reposById) - ) - hostIdsByProject.set(project, hostIds) - return hostIds - } -} - -// Why: mergePreviousProjectMetadata scans the whole catalog's repo key set; a view holding only this pair's repos keeps that scan per-project. -function restrictReposToProjectPair( - previous: Project, - current: Project, - reposById: ReadonlyMap -): Map { - const restricted = new Map() - for (const project of [previous, current]) { - for (const repoId of project.sourceRepoIds) { - const matches = reposById.get(repoId) - if (matches) { - restricted.set(repoId, matches) - } - } - } - return restricted -} - -function mergeFetchedProjectCompatibilityForHost({ - previous, - fetched, - repos, - hostId -}: { - previous: Pick - fetched: Pick - repos: readonly Repo[] - hostId: string -}): Pick { - const setupBelongsToFetchedCatalog = (setup: ProjectHostSetup): boolean => { - if (hostId !== LOCAL_EXECUTION_HOST_ID) { - return setup.hostId === hostId - } - const owner = parseExecutionHostId(setup.hostId) - // Why: desktop persistence owns local and direct-SSH setups; runtime setups stay authoritative on their remote Orca server. - return setup.hostId === LOCAL_EXECUTION_HOST_ID || owner?.kind === 'ssh' - } - const fetchedSetupsForHost = fetched.projectHostSetups.filter(setupBelongsToFetchedCatalog) - const preservedSetups = previous.projectHostSetups.filter( - (setup) => !setupBelongsToFetchedCatalog(setup) - ) - const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) - const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) - const reposById = getReposById(repos) - const currentRepoIds = new Set(repos.map((repo) => repo.id)) - const fetchedProjectHostIds = createProjectHostIdIndex( - fetched.projectHostSetups, - reposById, - getProjectHostIds - ) - const previousProjectHostIds = createProjectHostIdIndex( - previous.projectHostSetups, - reposById, - getProjectHostIds - ) - const currentProjectOwnerHostIds = createProjectHostIdIndex( - projectHostSetups, - reposById, - getExplicitProjectHostIds - ) - const projectHasCurrentOwnerOutsideHost = (project: Project): boolean => { - for (const ownerHostId of currentProjectOwnerHostIds(project)) { - if (ownerHostId !== hostId) { - return true - } - } - return false - } - const fetchedProjects = fetched.projects - .filter((project) => { - const previousProject = previousProjectById.get(project.id) - // Why: repo-derived compatibility projects include every host; a one-host refresh should only reconcile or prune that host's ownership. - return ( - fetchedProjectHostIds(project).has(hostId) || - (previousProject ? previousProjectHostIds(previousProject).has(hostId) : false) - ) - }) - .map((project) => { - const previousProject = previousProjectById.get(project.id) - return previousProject - ? mergePreviousProjectMetadata( - previousProject, - project, - restrictReposToProjectPair(previousProject, project, reposById), - hostId - ) - : projectWithCurrentSourceRepoIds(project, currentRepoIds) - }) - const fetchedProjectIds = new Set(fetchedProjects.map((project) => project.id)) - const preservedProjects = previous.projects.filter( - (project) => - !fetchedProjectIds.has(project.id) && - (!previousProjectHostIds(project).has(hostId) || projectHasCurrentOwnerOutsideHost(project)) - ) - // Why: both merges always allocate (sourceRepoIds is rebuilt per project, and fetched setups - // arrive freshly cloned over IPC), so reconcile against `previous` to recover identity when a - // refresh changed nothing. Each key is what the producing merge already dedups by. - return { - projects: reconcileCatalogRows( - previous.projects, - mergeProjectCompatibilityProjects( - preservedProjects.map((project) => { - const sourceRepoIds = getSourceRepoIdsOutsideHost(project, reposById, hostId) - return sourceRepoIds.length === project.sourceRepoIds.length - ? project - : { ...project, sourceRepoIds } - }), - fetchedProjects - ), - (project) => project.id - ), - projectHostSetups: reconcileCatalogRows( - previous.projectHostSetups, - projectHostSetups, - getProjectHostSetupOwnerKey - ) - } -} - -// Why: returning `base` unchanged keeps referential-equality selectors quiet, so a -// `repos:changed` echo doesn't re-force folder path-status fetches for every row. -function mergeByIdentity( - base: readonly T[], - overlay: readonly T[], - getIdentity: (entry: T) => string -): readonly T[] { - const merged = [...base] - const indexById = new Map(merged.map((entry, index) => [getIdentity(entry), index])) - let changed = false - for (const entry of overlay) { - const identity = getIdentity(entry) - const index = indexById.get(identity) - if (index === undefined) { - indexById.set(identity, merged.length) - merged.push(entry) - changed = true - continue - } - if (structuralValuesEqual(merged[index], entry)) { - continue - } - merged[index] = entry - changed = true - } - return changed ? merged : base -} - -// Why: `preserved` keeps `previous`'s order and element refs, so an equal-length no-op -// merge can hand the original store array straight back. -function unchangedMergeSource( - previous: readonly T[], - preserved: readonly T[], - merged: readonly T[] -): readonly T[] { - return merged === preserved && preserved.length === previous.length ? previous : merged -} - -// Why: the sidebar effect watching these catalog arrays is the only thing that refills the -// folder path-status cache, so a no-op refetch must not wipe it — nothing would repopulate it. -// Element identity only; `structuralValuesEqual` is the structural counterpart. -function arrayElementsUnchanged(next: readonly T[], previous: readonly T[]): boolean { - return ( - next === previous || - (next.length === previous.length && next.every((row, index) => row === previous[index])) - ) -} - -function mergeFetchedReposForHost( - previous: readonly Repo[], - fetched: readonly Repo[], - hostId: string -): readonly Repo[] { - const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched) - const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoHostIdentity)) - const preserved = previous.filter((repo) => { - const existingHostId = getRepoExecutionHostId(repo) - return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) - }) - return reconcileFetchedRepos( - previous, - mergeByIdentity(preserved, fetchedWithProjectGroups, getRepoHostIdentity) - ) -} - -function applyInheritedProjectGroups(previous: readonly Repo[], fetched: readonly Repo[]): Repo[] { - const projectGroupIdByProject = new Map() - for (const repo of previous) { - const projectGroupId = - repo.projectGroupId === undefined ? undefined : (repo.projectGroupId ?? null) - if (projectGroupId === undefined) { - continue - } - const projectId = getProjectIdentityKey(repo) - if (projectId.startsWith('repo:')) { - continue - } - if (!projectGroupIdByProject.has(projectId)) { - projectGroupIdByProject.set(projectId, projectGroupId) - } - } - if (projectGroupIdByProject.size === 0) { - return [...fetched] - } - return fetched.map((repo) => { - if (repo.projectGroupId !== undefined) { - return repo - } - const inheritedProjectGroupId = projectGroupIdByProject.get(getProjectIdentityKey(repo)) - if (inheritedProjectGroupId === undefined) { - return repo - } - // Why: project groups are a local affordance; runtime copies of the same canonical project should appear in the user's existing group. - return { ...repo, projectGroupId: inheritedProjectGroupId } - }) -} - -function mergeProjectCompatibilityForHostRepoChange({ - previous, - nextRepos, - hostId -}: { - previous: Pick - nextRepos: readonly Repo[] - hostId: string -}): Pick { - return mergeFetchedProjectCompatibilityForHost({ - previous, - fetched: projectCompatibilityFromRepos(nextRepos), - repos: nextRepos, - hostId - }) -} - -function getProjectGroupHostIdentity(group: ProjectGroup): string { - return JSON.stringify([getProjectGroupHostId(group), group.id]) -} - -function mergeFetchedProjectGroupsForHost( - previous: readonly ProjectGroup[], - fetched: ProjectGroup[], - hostId: string -): readonly ProjectGroup[] { - const fetchedIdentities = new Set(fetched.map(getProjectGroupHostIdentity)) - const preserved = previous.filter((group) => { - const existingHostId = getProjectGroupHostId(group) - return ( - !catalogOwnsHost(hostId, existingHostId) || - fetchedIdentities.has(getProjectGroupHostIdentity(group)) - ) - }) - return unchangedMergeSource( - previous, - preserved, - mergeByIdentity(preserved, fetched, getProjectGroupHostIdentity) - ) -} - -function getFolderWorkspaceHostId( - workspace: FolderWorkspace, - projectGroups: readonly ProjectGroup[] -): ExecutionHostId { - const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id - if (explicitHostId) { - return explicitHostId - } - if (workspace.connectionId) { - return toSshExecutionHostId(workspace.connectionId) - } - const matchingHosts = new Set( - projectGroups - .filter((group) => group.id === workspace.projectGroupId) - .map(getProjectGroupHostId) - ) - return matchingHosts.size === 1 - ? ([...matchingHosts][0] as ExecutionHostId) - : LOCAL_EXECUTION_HOST_ID -} - -function getFolderWorkspaceHostIdentity( - workspace: FolderWorkspace, - projectGroups: readonly ProjectGroup[] -): string { - return JSON.stringify([getFolderWorkspaceHostId(workspace, projectGroups), workspace.id]) -} - -// Why: a group id can exist on several hosts; only the deleted owner's rows may be cascaded away. -function applyProjectGroupDeleteCascade( - s: Pick, - groupId: string, - ownerHostId: ExecutionHostId | null -): Pick { - const ownsRowHost = (rowHostId: string): boolean => - ownerHostId ? catalogOwnsHost(ownerHostId, rowHostId) : true - const ownerGroups = s.projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group))) - const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) - const isDeletedGroup = (group: ProjectGroup): boolean => - deletedGroupIds.has(group.id) && ownsRowHost(getProjectGroupHostId(group)) - return { - projectGroups: s.projectGroups.filter((group) => !isDeletedGroup(group)), - folderWorkspaces: s.folderWorkspaces.filter( - (workspace) => - !deletedGroupIds.has(workspace.projectGroupId) || - // Why: resolve the workspace's host against the pre-delete group list, which still holds its owner row. - !ownsRowHost(getFolderWorkspaceHostId(workspace, s.projectGroups)) - ), - repos: s.repos.map((repo) => - repo.projectGroupId && - deletedGroupIds.has(repo.projectGroupId) && - ownsRowHost(getRepoExecutionHostId(repo)) - ? { ...repo, projectGroupId: null } - : repo - ), - folderWorkspacePathStatuses: {} - } -} - -function getFolderWorkspaceUpdateIdentity( - hostId: ExecutionHostId, - folderWorkspaceId: string -): string { - return `${hostId}\0${folderWorkspaceId}` -} - -function mergeFetchedFolderWorkspacesForHost({ - previous, - fetched, - projectGroups, - hostId -}: { - previous: readonly FolderWorkspace[] - fetched: FolderWorkspace[] - projectGroups: readonly ProjectGroup[] - hostId: string -}): readonly FolderWorkspace[] { - const fetchedIdentities = new Set( - fetched.map((workspace) => getFolderWorkspaceHostIdentity(workspace, projectGroups)) - ) - const preserved = previous.filter((workspace) => { - const existingHostId = getFolderWorkspaceHostId(workspace, projectGroups) - return ( - !catalogOwnsHost(hostId, existingHostId) || - fetchedIdentities.has(getFolderWorkspaceHostIdentity(workspace, projectGroups)) - ) - }) - return unchangedMergeSource( - previous, - preserved, - mergeByIdentity(preserved, fetched, (workspace) => - getFolderWorkspaceHostIdentity(workspace, projectGroups) - ) - ) -} - -type FetchedRepoCatalog = { - repos: readonly Repo[] - projectHostSetupCompatibility: ProjectHostSetupProjection - hostId: ReturnType -} - -type FetchedProjectGroupCatalog = { - projectGroups: ProjectGroup[] - hostId: ReturnType -} - -type FetchedFolderWorkspaceCatalog = { - folderWorkspaces: FolderWorkspace[] - hostId: ReturnType -} - -function getFolderWorkspaceCatalogReplacementIdentities( - catalog: FetchedFolderWorkspaceCatalog, - currentFolderWorkspaces: readonly FolderWorkspace[], - projectGroups: readonly ProjectGroup[] -): Set { - const replacedIdentities = new Set( - catalog.folderWorkspaces.map((workspace) => - getFolderWorkspaceUpdateIdentity( - getFolderWorkspaceHostId(workspace, projectGroups), - workspace.id - ) - ) - ) - for (const workspace of currentFolderWorkspaces) { - const hostId = getFolderWorkspaceHostId(workspace, projectGroups) - if (catalogOwnsHost(catalog.hostId, hostId)) { - replacedIdentities.add(getFolderWorkspaceUpdateIdentity(hostId, workspace.id)) - } - } - return replacedIdentities -} - -async function fetchRepoCatalogForTarget( - target: ReturnType -): Promise { - const fetchedRepos = - target.kind === 'local' - ? await window.api.repos.list() - : ( - await callRuntimeRpc<{ repos: Repo[] }>(target, 'repo.list', undefined, { - timeoutMs: 15_000, - reuseRecentCompatibilityFailure: true - }) - ).repos - const repos = fetchedRepos.map((repo) => repoWithFetchedOwner(repo, target)) - return { - repos, - projectHostSetupCompatibility: await fetchProjectHostSetupCompatibility(target, repos), - hostId: getRuntimeTargetHostId(target) - } -} - -function mergeFetchedRepoCatalog( - catalog: FetchedRepoCatalog, - currentRepos: readonly Repo[] -): { - repos: readonly Repo[] - projectHostSetupCompatibility: ProjectHostSetupProjection - hostId: ReturnType -} { - const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId) - return { - repos, - projectHostSetupCompatibility: catalog.projectHostSetupCompatibility, - hostId: catalog.hostId - } -} - -function reconcileSupersededSshRepos( - repos: readonly Repo[], - state: Pick -): SshRepoReconciliation { - return reconcileReadoptedSshRepoRows(repos, state.pendingSshRepoReadoptions) -} - -function filterSetupsForPrunedRepoRows( - setups: readonly ProjectHostSetup[], - mergedRepos: readonly Repo[], - reconciledRepos: readonly Repo[] -): readonly ProjectHostSetup[] { - const survivingOwners = new Set( - reconciledRepos.map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`) - ) - const prunedOwners = new Set( - mergedRepos - .filter((repo) => !survivingOwners.has(`${getRepoExecutionHostId(repo)}:${repo.id}`)) - .map((repo) => `${getRepoExecutionHostId(repo)}:${repo.id}`) - ) - // Why: this result feeds the compat merge as `previous`, so an unconditional copy would discard - // the identity that merge is about to try to preserve. - if (prunedOwners.size === 0) { - return setups - } - const filtered = setups.filter( - (setup) => !setup.repoId || !prunedOwners.has(`${setup.hostId}:${setup.repoId}`) - ) - return filtered.length === setups.length ? setups : filtered -} - -function reconcileReadoptedSshWorktreeState( - state: Pick, - readoptions: readonly SshRepoReadoption[] -): Pick { - const worktreesByRepo = reconcileReadoptedSshWorktreesByRepo(state.worktreesByRepo, readoptions) - const detectedRows = Object.fromEntries( - Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ - repoId, - result.worktrees - ]) - ) - const reconciledDetectedRows = reconcileReadoptedSshWorktreesByRepo(detectedRows, readoptions) - const detectedWorktreesByRepo = - reconciledDetectedRows === detectedRows - ? state.detectedWorktreesByRepo - : Object.fromEntries( - Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ - repoId, - { ...result, worktrees: reconciledDetectedRows[repoId] } - ]) - ) - return { - worktreesByRepo, - detectedWorktreesByRepo, - sortEpoch: worktreesByRepo === state.worktreesByRepo ? state.sortEpoch : state.sortEpoch + 1 - } -} - -function projectCompatibilityForReconciledRepos( - repos: readonly Repo[], - fetched: ProjectHostSetupProjection -): Pick { - return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched) -} - -function filterTrustedOrcaHooksToValidRepos( - trust: AppState['trustedOrcaHooks'], - validRepoIds: Set -): AppState['trustedOrcaHooks'] { - const next: AppState['trustedOrcaHooks'] = {} - for (const [repoId, entry] of Object.entries(trust)) { - if (validRepoIds.has(repoId)) { - next[repoId] = entry - } - } - return next -} - -function clearRestoredFolderWorkspaceSessionOwners( - owners: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] | undefined, - state: Pick -): AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] { - const next: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] = {} - for (const [key, hostId] of Object.entries(owners ?? {})) { - const scope = parseWorkspaceKey(key) - if (scope?.type !== 'folder') { - next[key] = hostId - continue - } - const workspace = state.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId) - if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) { - // Why: ownership resolves via the project group; if that catalog is still missing, keep the restored host owner so a session write doesn't move runtime tabs local. - next[key] = hostId - } - } - return next -} - -async function fetchProjectGroupCatalogForTarget( - target: ReturnType -): Promise { - const fetchedGroups = - target.kind === 'local' - ? await window.api.projectGroups.list() - : ( - await callRuntimeRpc<{ groups: ProjectGroup[] }>(target, 'projectGroup.list', undefined, { - timeoutMs: 15_000, - reuseRecentCompatibilityFailure: true - }) - ).groups - return { - projectGroups: fetchedGroups.map((group) => projectGroupWithFetchedOwner(group, target)), - hostId: getRuntimeTargetHostId(target) - } -} - -function mergeFetchedProjectGroupCatalog( - catalog: FetchedProjectGroupCatalog, - currentProjectGroups: readonly ProjectGroup[] -): { projectGroups: readonly ProjectGroup[]; hostId: ReturnType } { - return { - projectGroups: mergeFetchedProjectGroupsForHost( - currentProjectGroups, - catalog.projectGroups, - catalog.hostId - ), - hostId: catalog.hostId - } -} - -async function fetchFolderWorkspaceCatalogForTarget( - target: ReturnType, - projectGroups: readonly ProjectGroup[] -): Promise { - const fetchedFolderWorkspaces = - target.kind === 'local' - ? await window.api.folderWorkspaces.list() - : ( - await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>( - target, - 'folderWorkspace.list', - undefined, - { timeoutMs: 15_000, reuseRecentCompatibilityFailure: true } - ) - ).folderWorkspaces - return { - folderWorkspaces: fetchedFolderWorkspaces.map((workspace) => - folderWorkspaceWithFetchedOwner(workspace, target, projectGroups) - ), - hostId: getRuntimeTargetHostId(target) - } -} - -function folderWorkspaceWithFetchedOwner( - workspace: FolderWorkspace, - target: ReturnType, - projectGroups: readonly ProjectGroup[] -): FolderWorkspace { - return { - ...workspace, - executionHostId: - target.kind === 'environment' - ? getRuntimeTargetHostId(target) - : getFolderWorkspaceHostId(workspace, projectGroups) - } -} - -function mergeFetchedFolderWorkspaceCatalog( - catalog: FetchedFolderWorkspaceCatalog, - currentFolderWorkspaces: readonly FolderWorkspace[], - projectGroups: readonly ProjectGroup[] -): { - folderWorkspaces: readonly FolderWorkspace[] - hostId: ReturnType -} { - return { - folderWorkspaces: mergeFetchedFolderWorkspacesForHost({ - previous: currentFolderWorkspaces, - fetched: catalog.folderWorkspaces, - projectGroups, - hostId: catalog.hostId - }), - hostId: catalog.hostId - } -} - -async function reconcileFailedFolderWorkspaceUpdate(args: { - target: ReturnType - folderWorkspaceId: string - updateIdentity: string - ownerHostId: ExecutionHostId - ticket: FolderWorkspaceUpdateTicket - coordinator: FolderWorkspaceUpdateCoordinatorInstance - set: Parameters>[0] - get: Parameters>[1] -}): Promise { - try { - const catalog = await fetchFolderWorkspaceCatalogForTarget( - args.target, - args.get().projectGroups - ) - const latestFields = args.coordinator.latestFields(args.updateIdentity, args.ticket) - if (latestFields.length === 0) { - return - } - const refreshed = catalog.folderWorkspaces.find( - (workspace) => workspace.id === args.folderWorkspaceId - ) - args.set((state) => ({ - folderWorkspaces: refreshed - ? state.folderWorkspaces.map((workspace) => - workspace.id === args.folderWorkspaceId && - getFolderWorkspaceHostId(workspace, state.projectGroups) === args.ownerHostId - ? mergeFolderWorkspaceUpdateResponse(workspace, refreshed, latestFields) - : workspace - ) - : state.folderWorkspaces.filter( - (workspace) => - workspace.id !== args.folderWorkspaceId || - getFolderWorkspaceHostId(workspace, state.projectGroups) !== args.ownerHostId - ), - ...(folderWorkspaceUpdateInvalidatesPathStatus(latestFields) || !refreshed - ? { folderWorkspacePathStatuses: {} } - : {}) - })) - if (!refreshed) { - args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)]) - } - } catch (err) { - console.warn('Failed to reconcile folder workspace after update failure:', err) - } -} - -async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[]> { - try { - return (await window.api.runtimeEnvironments.list()) ?? [] - } catch (err) { - console.warn('Failed to list runtime environments for all-host load:', err) - return [] - } -} - -function settingsForRepoOwner( - state: Pick, - repoId: string, - hostId?: ExecutionHostId -) { - const repo = findRepoForHost(state.repos, repoId, { settings: state.settings, hostId }) - if (!repo) { - return state.settings - } - if (!repo.executionHostId && !repo.connectionId) { - return state.settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - if ( - (parsed?.kind === 'local' || parsed?.kind === 'ssh') && - state.settings?.activeRuntimeEnvironmentId - ) { - return { ...state.settings, activeRuntimeEnvironmentId: null } - } - return state.settings -} - -function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { - if (request.scope === 'project-group') { - return `project-group:${request.projectGroupId}` - } - if (request.scope === 'path') { - return `path:${request.connectionId ?? ''}:${request.path}` - } - return `folder-workspace:${request.folderWorkspaceId}` -} - -function getRuntimeTargetCachePrefix( - settings: Pick | null | undefined -): string { - const target = getActiveRuntimeTarget(settings) - return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` -} - -type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId?: string | null } -type AddRepoPathRouteOptions = { runtimeEnvironmentId?: string | null } -type RuntimeCatalogFetchOptions = { runtimeEnvironmentId?: string | null } - -function getFolderWorkspacePathStatusRouteSettings( - options: FolderWorkspacePathStatusRouteOptions | undefined, - fallbackSettings: GlobalSettings | null -): Pick | null | undefined { - return options && 'runtimeEnvironmentId' in options - ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } - : fallbackSettings -} - -function getAddRepoPathRouteSettings( - options: AddRepoPathRouteOptions | undefined, - fallbackSettings: GlobalSettings | null -): Pick | null | undefined { - return options && 'runtimeEnvironmentId' in options - ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } - : fallbackSettings -} - -function folderWorkspaceUpdateInvalidatesPathStatus( - fields: readonly FolderWorkspaceUpdateField[] -): boolean { - return fields.includes('folderPath') -} - -function mergeFolderWorkspaceUpdateResponse( - current: FolderWorkspace, - updated: FolderWorkspace, - fields: readonly FolderWorkspaceUpdateField[], - options: { rejectOlderResponse?: boolean } = {} -): FolderWorkspace { - if ( - fields.length === 0 || - (options.rejectOlderResponse && updated.updatedAt < current.updatedAt) - ) { - return current - } - const next = { ...current } - for (const field of fields) { - // Why: coalesced activity can land an older response after later local bumps. - if (field === 'lastActivityAt') { - next.lastActivityAt = Math.max(current.lastActivityAt, updated.lastActivityAt) - continue - } - Object.assign(next, { [field]: updated[field] }) - } - next.updatedAt = Math.max(current.updatedAt, updated.updatedAt) - return next -} - -function getRuntimeEnvironmentDisplayName(state: AppState, environmentId: string): string { - const environment = state.runtimeEnvironments.find((entry) => entry.id === environmentId) - return environment?.name || environmentId -} - -async function fetchRuntimeAddProjectPathStatus(args: { - target: Extract, { kind: 'environment' }> - path: string -}): Promise { - await assertRuntimeEnvironmentCapability( - args.target.environmentId, - FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, - translate( - 'auto.store.slices.repos.2975400634', - 'Update Orca server to open non-Git folders on this runtime.' - ), - 15_000 - ) - try { - const { status } = await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>( - args.target, - 'folderWorkspace.getPathStatus', - { scope: 'path', path: args.path }, - { timeoutMs: 15_000 } - ) - return status - } catch (err) { - console.warn('Failed to check runtime folder path status:', err) - return null - } -} - -function getFolderWorkspaceStatusRequestSnapshot( - state: Pick, - request: FolderWorkspacePathStatusRequest -): string | null { - if (request.scope === 'path') { - const candidateRepos = state.repos.filter((repo) => - isPathInsideOrEqual(request.path, repo.path) - ) - const relevantConnectionIds = new Set() - if (request.connectionId) { - relevantConnectionIds.add(request.connectionId) - } - for (const repo of candidateRepos) { - if (repo.connectionId) { - relevantConnectionIds.add(repo.connectionId) - } - } - const sshFingerprint = [...relevantConnectionIds] - .map( - (connectionId) => - `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` - ) - .sort() - .join('|') - const repoFingerprint = candidateRepos - .map( - (repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}` - ) - .sort() - .join('|') - return [request.path, '', request.connectionId ?? '', sshFingerprint, repoFingerprint].join( - '\0' - ) - } - - const scope = - request.scope === 'project-group' - ? state.projectGroups.find((group) => group.id === request.projectGroupId) - : state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId) - const projectGroup = - request.scope === 'project-group' - ? scope && 'parentPath' in scope - ? scope - : null - : scope && 'projectGroupId' in scope - ? state.projectGroups.find((group) => group.id === scope.projectGroupId) - : null - const folderPath = - request.scope === 'project-group' - ? scope && 'parentPath' in scope - ? scope.parentPath - : null - : scope && 'folderPath' in scope - ? scope.folderPath - : null - const projectGroupId = - request.scope === 'project-group' - ? request.projectGroupId - : scope && 'projectGroupId' in scope - ? scope.projectGroupId - : null - const scopeConnectionId = - request.scope === 'project-group' - ? scope && 'parentPath' in scope - ? scope.connectionId - : null - : scope && 'folderPath' in scope - ? (scope.connectionId ?? projectGroup?.connectionId) - : null - if (!folderPath || !projectGroupId) { - return null - } - const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId) - const candidateRepos = state.repos.filter( - (repo) => - (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || - isPathInsideOrEqual(folderPath, repo.path) - ) - const relevantConnectionIds = new Set() - if (scopeConnectionId) { - relevantConnectionIds.add(scopeConnectionId) - } - for (const repo of candidateRepos) { - if (repo.connectionId) { - relevantConnectionIds.add(repo.connectionId) - } - } - const sshFingerprint = [...relevantConnectionIds] - .map( - (connectionId) => - `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` - ) - .sort() - .join('|') - const repoFingerprint = candidateRepos - .map( - (repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}` - ) - .sort() - .join('|') - return [ - folderPath, - projectGroupId, - scopeConnectionId ?? '', - sshFingerprint, - repoFingerprint - ].join('\0') -} - -function getFreshFolderWorkspacePathStatusFromCache(args: { - entry: FolderWorkspacePathStatusCacheEntry | undefined - requestSnapshot: string | null -}): FolderWorkspacePathStatus | null { - const { entry, requestSnapshot } = args - if (!entry || requestSnapshot === null || entry.requestSnapshot !== requestSnapshot) { - return null - } - return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null -} - -function getFolderWorkspacePathStatusRequestSnapshotForRead( - state: AppState, - request: FolderWorkspacePathStatusRequest -): string | null { - return getFolderWorkspaceStatusRequestSnapshot(state, request) -} - -export type RepoSlice = { - repos: readonly Repo[] - projects: readonly Project[] - projectHostSetups: readonly ProjectHostSetup[] - projectGroups: readonly ProjectGroup[] - folderWorkspaces: readonly FolderWorkspace[] - folderWorkspacePathStatuses: Record - activeRepoId: string | null - // Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020). - reposFetchGeneration: number - pendingSshRepoReadoptions: readonly SshRepoReadoption[] - recordSshRepoReadoptions: (readoptions: readonly SshRepoReadoption[]) => void - fetchRepos: (options?: RuntimeCatalogFetchOptions) => Promise - fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise - awaitLocalRepoCatalogSettlement: () => Promise - fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise - fetchProjectGroups: (options?: RuntimeCatalogFetchOptions) => Promise - fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise - fetchFolderWorkspaces: (options?: RuntimeCatalogFetchOptions) => Promise - fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise - addRepo: () => Promise - addRepoPath: ( - path: string, - kind?: 'git' | 'folder', - options?: AddRepoPathRouteOptions - ) => Promise - setupProjectExistingFolder: ( - args: ProjectHostSetupExistingFolderArgs - ) => Promise - createProjectHostSetup: ( - args: ProjectHostSetupCreateArgs - ) => Promise - updateProjectHostSetup: ( - args: ProjectHostSetupUpdateArgs - ) => Promise - deleteProjectHostSetup: ( - args: ProjectHostSetupDeleteArgs - ) => Promise - setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise - addNonGitFolder: (path: string, options?: AddRepoPathRouteOptions) => Promise - scanNestedRepos: ( - path: string, - connectionId?: string, - controls?: NestedRepoScanControls - ) => Promise - cancelNestedRepoScan: (scanId: string, options?: NestedRepoScanCancelOptions) => Promise - importNestedRepos: (args: { - parentPath: string - groupName: string - projectPaths: string[] - connectionId?: string - scanId?: string - runtimeEnvironmentId?: string | null - mode: 'group' | 'separate' - }) => Promise - createProjectGroup: (name: string) => Promise - createFolderWorkspace: ( - args: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - linkedTask?: FolderWorkspace['linkedTask'] - linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }, - options?: FolderWorkspacePathStatusRouteOptions - ) => Promise - getFolderWorkspacePathStatusCacheKey: ( - request: FolderWorkspacePathStatusRequest, - options?: FolderWorkspacePathStatusRouteOptions - ) => string - getFreshFolderWorkspacePathStatus: ( - request: FolderWorkspacePathStatusRequest, - options?: FolderWorkspacePathStatusRouteOptions - ) => FolderWorkspacePathStatus | null - fetchFolderWorkspacePathStatus: ( - request: FolderWorkspacePathStatusRequest, - options?: { force?: boolean } & FolderWorkspacePathStatusRouteOptions - ) => Promise - updateFolderWorkspace: ( - folderWorkspaceId: string, - updates: FolderWorkspaceUpdates, - options?: { executionHostId?: ExecutionHostId } - ) => Promise - deleteFolderWorkspace: ( - folderWorkspaceId: string, - options?: { executionHostId?: ExecutionHostId } - ) => Promise - // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call. - updateProjectGroup: ( - groupId: string, - updates: Partial>, - options?: { hostId?: ExecutionHostId } - ) => Promise - deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise - deleteProjectGroupWithContainedProjects: ( - groupId: string, - options: DeleteProjectGroupWithContainedProjectsOptions - ) => Promise - moveProjectToGroup: ( - projectId: string, - groupId: string | null, - order?: number - ) => Promise - // options.hostId disambiguates which host's row to remove when the id exists on multiple hosts; else the focused host is assumed. - // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. - removeProject: ( - projectId: string, - options?: { hostId?: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } - ) => Promise - updateProject: (projectId: string, updates: ProjectUpdate) => Promise - // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. - updateRepo: ( - projectId: string, - updates: RepoUpdate, - options?: { hostId?: ExecutionHostId } - ) => Promise - setActiveRepo: (projectId: string | null) => void - reorderRepos: (orderedIds: string[]) => Promise -} - -type LocalRepoCatalogFetchOutcome = - | { status: 'fulfilled' } - | { status: 'rejected'; reason: unknown } - -const latestLocalRepoCatalogFetchByStore = new WeakMap< - () => AppState, - Promise ->() -const latestRepoCatalogGenerationByHostByStore = new WeakMap<() => AppState, Map>() -const latestAllHostRepoCatalogGenerationByStore = new WeakMap<() => AppState, number>() -const latestHostCatalogGenerationByStore = new WeakMap<() => AppState, Map>() - -function claimHostCatalogFence( - get: () => AppState, - kind: HostCatalogKind, - target: ReturnType -): HostCatalogFence { - const key = `${kind}:${getRuntimeTargetHostId(target)}` - let generations = latestHostCatalogGenerationByStore.get(get) - if (!generations) { - generations = new Map() - latestHostCatalogGenerationByStore.set(get, generations) - } - const generation = (generations.get(key) ?? 0) + 1 - generations.set(key, generation) - return { - key, - generation, - target, - sshStateGeneration: - target.kind === 'environment' ? getEnvironmentSshStateGeneration(target.environmentId) : null, - runtimeConnectionGeneration: - target.kind === 'environment' - ? getRuntimeEnvironmentConnectionGeneration(target.environmentId) - : null - } -} - -function isHostCatalogFenceCurrent(get: () => AppState, fence: HostCatalogFence): boolean { - if (latestHostCatalogGenerationByStore.get(get)?.get(fence.key) !== fence.generation) { - return false - } - if (fence.target.kind !== 'environment') { - return true - } - return ( - !isRemovedRuntimeHostId( - getRuntimeTargetHostId(fence.target), - get().removedRuntimeEnvironmentIds - ) && - getEnvironmentSshStateGeneration(fence.target.environmentId) === fence.sshStateGeneration && - getRuntimeEnvironmentConnectionGeneration(fence.target.environmentId) === - fence.runtimeConnectionGeneration - ) -} - -function startLocalRepoCatalogFetch( - get: () => AppState -): (outcome: LocalRepoCatalogFetchOutcome) => void { - let settle: (outcome: LocalRepoCatalogFetchOutcome) => void = () => undefined - const settlement = new Promise((resolve) => { - settle = resolve - }) - latestLocalRepoCatalogFetchByStore.set(get, settlement) - return settle -} - -async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise { - while (true) { - const pending = latestLocalRepoCatalogFetchByStore.get(get) - if (!pending) { - return - } - const outcome = await pending - if (latestLocalRepoCatalogFetchByStore.get(get) === pending) { - if (outcome.status === 'rejected') { - throw outcome.reason - } - return - } - } -} - -function claimRepoCatalogGeneration(get: () => AppState, hostId: string, generation: number): void { - let generations = latestRepoCatalogGenerationByHostByStore.get(get) - if (!generations) { - generations = new Map() - latestRepoCatalogGenerationByHostByStore.set(get, generations) - } - if ((generations.get(hostId) ?? 0) < generation) { - generations.set(hostId, generation) - } -} - -function isLatestRepoCatalogGeneration( - get: () => AppState, - hostId: string, - generation: number -): boolean { - return latestRepoCatalogGenerationByHostByStore.get(get)?.get(hostId) === generation -} - -export const createRepoSlice: StateCreator = (set, get) => ({ - repos: [], - projects: [], - projectHostSetups: [], - projectGroups: [], - folderWorkspaces: [], - folderWorkspacePathStatuses: {}, - activeRepoId: null, - reposFetchGeneration: 0, - pendingSshRepoReadoptions: [], - - recordSshRepoReadoptions: (readoptions) => - set((s) => { - // Why: SshPane importConfig() often reports [] on every Manage-pane open. - if (readoptions.length === 0 && s.pendingSshRepoReadoptions.length === 0) { - return s - } - const pendingSshRepoReadoptions = mergeSshRepoReadoptions( - s.pendingSshRepoReadoptions, - readoptions - ) - const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions) - const repos = reconciliation.repos - const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions) - const remainingSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos) - const compatibility = mergeProjectHostSetupCompatibility( - projectCompatibilityFromRepos(repos), - { - projects: s.projects, - setups: remainingSetups - } - ) - // Why: mergeProjectHostSetupCompatibility always allocates; a no-op readoption - // must not churn catalog identity. This write is all-repos, not host-scoped, - // so it cannot go through mergeFetchedProjectCompatibilityForHost. Reconcile - // hands the store arrays straight back when nothing moved, so writing them - // unconditionally still leaves identity-keyed selectors untouched. - const projects = reconcileCatalogRows( - s.projects, - compatibility.projects, - (project) => project.id - ) - const projectHostSetups = reconcileCatalogRows( - s.projectHostSetups, - compatibility.projectHostSetups, - getProjectHostSetupOwnerKey - ) - return { - repos, - pendingSshRepoReadoptions: reconciliation.pendingReadoptions, - ...worktreeState, - projects, - projectHostSetups - } - }), - - fetchRepos: async (options) => { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) - const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void = - target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined - let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } - // Why: overlapping repos:changed fetches can resolve out of order; a stale one must not overwrite a newer result and resurrect deleted projects (#7020). - let generation = 0 - set((s) => { - generation = s.reposFetchGeneration + 1 - return { reposFetchGeneration: generation } - }) - const targetHostId = getRuntimeTargetHostId(target) - claimRepoCatalogGeneration(get, targetHostId, generation) - try { - const catalog = await fetchRepoCatalogForTarget(target) - // A newer same-host fetch superseded us while we awaited — drop this stale result. - if (!isLatestRepoCatalogGeneration(get, targetHostId, generation)) { - return - } - let finalizedHostRepos: Repo[] = [] - set((s) => { - // Why: an in-flight fetch for a just-removed env would re-add purged repos and stick; skip only when the env was tombstoned, not merely unhydrated (#8881). - if (isRemovedRuntimeHostId(catalog.hostId, s.removedRuntimeEnvironmentIds)) { - return s - } - // Why: re-adoption leaves a stale row on the old SSH target id (a ghost that fails "SSH target not found"); drop rows a live-host sibling supersedes. - const result = mergeFetchedRepoCatalog(catalog, s.repos) - const reconciliation = reconcileSupersededSshRepos(result.repos, s) - const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) - const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) - const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) - const projectCompatibility = projectCompatibilityForReconciledRepos( - prunedRepos, - catalog.projectHostSetupCompatibility - ) - const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ - previous: { - projects: s.projects, - projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, - result.repos, - prunedRepos - ) - }, - fetched: projectCompatibility, - repos: prunedRepos, - hostId: result.hostId - }) - finalizedHostRepos = prunedRepos.filter( - (repo) => getRepoExecutionHostId(repo) === result.hostId - ) - return { - repos: prunedRepos, - pendingSshRepoReadoptions: reconciliation.pendingReadoptions, - ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), - ...mergedProjectCompatibility, - ...(arrayElementsUnchanged(prunedRepos, s.repos) - ? {} - : { folderWorkspacePathStatuses: {} }), - activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - s.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities - ) - } - }) - scheduleSafeAutoForkSync(get, finalizedHostRepos) - } catch (err) { - localCatalogOutcome = { status: 'rejected', reason: err } - console.error('Failed to fetch repos:', err) - } finally { - settleLocalCatalog(localCatalogOutcome) - } - }, - - fetchRuntimeEnvironmentRepos: async (environmentId) => { - const requestGeneration = (runtimeRepoFetchGenerationByEnvironment.get(environmentId) ?? 0) + 1 - runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration) - const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) - const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) - let catalogGeneration = 0 - set((s) => { - catalogGeneration = s.reposFetchGeneration + 1 - return { reposFetchGeneration: catalogGeneration } - }) - const target = { kind: 'environment' as const, environmentId } - const targetHostId = getRuntimeTargetHostId(target) - claimRepoCatalogGeneration(get, targetHostId, catalogGeneration) - try { - const [catalog, visibilitySnapshot] = await Promise.all([ - fetchRepoCatalogForTarget(target), - readRuntimeWorktreeVisibilitySnapshot(environmentId) - ]) - const visibilityDefaults = visibilitySnapshot.defaults - if ( - runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || - !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || - getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || - getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration - ) { - return [] - } - let finalizedHostRepos: Repo[] = [] - set((s) => { - if ( - runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || - !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || - getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || - getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration - ) { - return s - } - // Why: skip merging a runtime env removed while this Connect-flow fetch was in flight, so purged repos aren't re-added (#8881). - if (isRemovedRuntimeHostId(catalog.hostId, s.removedRuntimeEnvironmentIds)) { - return s - } - const result = mergeFetchedRepoCatalog(catalog, s.repos) - const reconciliation = reconcileSupersededSshRepos(result.repos, s) - const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) - const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id)) - const validRepoHostIdentities = new Set(finalizedRepos.map(getRepoHostIdentity)) - const projectCompatibility = projectCompatibilityForReconciledRepos( - finalizedRepos, - catalog.projectHostSetupCompatibility - ) - const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ - previous: { - projects: s.projects, - projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, - result.repos, - finalizedRepos - ) - }, - fetched: projectCompatibility, - repos: finalizedRepos, - hostId: result.hostId - }) - finalizedHostRepos = finalizedRepos.filter( - (repo) => getRepoExecutionHostId(repo) === result.hostId - ) - return { - repos: finalizedRepos, - ...(visibilityDefaults === undefined - ? {} - : { - worktreeVisibilityDefaultsByHost: { - ...s.worktreeVisibilityDefaultsByHost, - [targetHostId]: visibilityDefaults - } - }), - ...(visibilityDefaults !== undefined && s.settings - ? getActiveRuntimeTarget(s.settings).kind === 'environment' && - s.settings.activeRuntimeEnvironmentId === environmentId - ? visibilityDefaults - ? { - settings: { - ...s.settings, - worktreeVisibilityDefaults: visibilityDefaults - }, - worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: environmentId, - worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: - visibilitySnapshot.sourceDefaultsSupported ? environmentId : null - } - : { - settings: Object.fromEntries( - Object.entries(s.settings).filter( - ([key]) => key !== 'worktreeVisibilityDefaults' - ) - ) as GlobalSettings, - worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null, - worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null - } - : {} - : {}), - pendingSshRepoReadoptions: reconciliation.pendingReadoptions, - ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), - ...mergedProjectCompatibility, - activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - s.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities - ) - } - }) - scheduleSafeAutoForkSync(get, finalizedHostRepos) - return finalizedHostRepos - } catch (err) { - console.error(`Failed to fetch repos for runtime environment ${environmentId}:`, err) - return [] - } - }, - - fetchReposForAllHosts: async (options) => { - const settleLocalCatalog = startLocalRepoCatalogFetch(get) - let generation = 0 - set((s) => { - generation = s.reposFetchGeneration + 1 - return { reposFetchGeneration: generation } - }) - latestAllHostRepoCatalogGenerationByStore.set(get, generation) - claimRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) - // Why: fetching only the active host hides every other host's repos ("my projects vanished"); load local + all runtime envs, each failing soft. - const applyCatalog = (catalog: FetchedRepoCatalog): void => { - // Why: a concurrent all-host refresh must not let the older catalog resurrect a migrated SSH owner. - if ( - latestAllHostRepoCatalogGenerationByStore.get(get) !== generation || - !isLatestRepoCatalogGeneration(get, catalog.hostId, generation) - ) { - return - } - let hostRepos: Repo[] = [] - set((s) => { - // Why: skip a catalog whose env was tombstoned mid-load (removed), not one merely absent from the not-yet-hydrated saved list (#8881). - if (isRemovedRuntimeHostId(catalog.hostId, s.removedRuntimeEnvironmentIds)) { - return s - } - const result = mergeFetchedRepoCatalog(catalog, s.repos) - const reconciliation = reconcileSupersededSshRepos(result.repos, s) - const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) - const projectCompatibility = projectCompatibilityForReconciledRepos( - finalizedRepos, - catalog.projectHostSetupCompatibility - ) - const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ - previous: { - projects: s.projects, - projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, - result.repos, - finalizedRepos - ) - }, - fetched: projectCompatibility, - repos: finalizedRepos, - hostId: result.hostId - }) - hostRepos = finalizedRepos.filter((repo) => getRepoExecutionHostId(repo) === result.hostId) - return { - repos: finalizedRepos, - pendingSshRepoReadoptions: reconciliation.pendingReadoptions, - ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), - ...mergedProjectCompatibility, - ...(arrayElementsUnchanged(finalizedRepos, s.repos) - ? {} - : { folderWorkspacePathStatuses: {} }), - activeRepoId: s.activeRepoId, - filterRepoIds: s.filterRepoIds, - setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds - } - }) - // Why: keep the safe-auto fork sync (as fetchRepos does) so cold-start, which now routes here, still updates safe-auto forks. - scheduleSafeAutoForkSync(get, hostRepos) - } - const validateRepoScopedUi = (): void => { - set((s) => { - const validRepoIds = new Set(s.repos.map((repo) => repo.id)) - const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) - return { - activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), - setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( - s.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities - ), - trustedOrcaHooks: filterTrustedOrcaHooksToValidRepos(s.trustedOrcaHooks, validRepoIds) - } - }) - } - - // Local first so local repos are present even if a remote fetch stalls. - let failed = false - let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } - try { - applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' })) - } catch (err) { - failed = true - localCatalogOutcome = { status: 'rejected', reason: err } - console.error('Failed to fetch local repos for all-host load:', err) - } - // Why: startup hydration needs the newest local catalog, not unreachable remote hosts. - settleLocalCatalog(localCatalogOutcome) - // Why: a newer local-only refresh must not cancel this load's unrelated remote catalogs. - if (latestAllHostRepoCatalogGenerationByStore.get(get) !== generation) { - return - } - if (options?.remoteHosts === 'skip') { - return - } - - const environments = await listRuntimeEnvironmentsForAllHostLoad() - // Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber. - await Promise.all( - environments.map(async (environment) => { - const target = { - kind: 'environment' as const, - environmentId: environment.id - } - claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation) - const [catalogResult, visibilitySnapshot] = await Promise.all([ - fetchRepoCatalogForTarget(target).then( - (catalog) => ({ ok: true as const, catalog }), - (error: unknown) => ({ ok: false as const, error }) - ), - readRuntimeWorktreeVisibilitySnapshot(environment.id) - ]) - const visibilityDefaults = visibilitySnapshot.defaults - const hostId = getRuntimeTargetHostId(target) - if ( - visibilityDefaults !== undefined && - latestAllHostRepoCatalogGenerationByStore.get(get) === generation && - isLatestRepoCatalogGeneration(get, hostId, generation) - ) { - set((state) => - isRemovedRuntimeHostId(hostId, state.removedRuntimeEnvironmentIds) - ? state - : { - worktreeVisibilityDefaultsByHost: { - ...state.worktreeVisibilityDefaultsByHost, - [hostId]: visibilityDefaults - }, - ...(getActiveRuntimeTarget(state.settings).kind === 'environment' && - state.settings?.activeRuntimeEnvironmentId === environment.id - ? { - worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: - visibilitySnapshot.sourceDefaultsSupported ? environment.id : null - } - : {}) - } - ) - } - if (catalogResult.ok) { - applyCatalog(catalogResult.catalog) - } else { - failed = true - console.warn( - `Skipped repos for runtime environment ${environment.id}:`, - catalogResult.error - ) - } - }) - ) - // Why: validate repo-scoped UI only after every host answers; first-paint loads only local repos, so an offline runtime would erase its saved filters. - if (!failed && get().reposFetchGeneration === generation) { - validateRepoScopedUi() - } - }, - - awaitLocalRepoCatalogSettlement: () => awaitLatestLocalRepoCatalogFetch(get), - - fetchProjectGroups: async (options) => { - try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) - const fence = claimHostCatalogFence(get, 'project-groups', target) - const catalog = await fetchProjectGroupCatalogForTarget(target) - if (!isHostCatalogFenceCurrent(get, fence)) { - return - } - set((current) => { - if (!isHostCatalogFenceCurrent(get, fence)) { - return current - } - const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, current.projectGroups) - return { - projectGroups, - ...(arrayElementsUnchanged(projectGroups, current.projectGroups) - ? {} - : { folderWorkspacePathStatuses: {} }) - } - }) - } catch (err) { - console.error('Failed to fetch project groups:', err) - } - }, - - fetchProjectGroupsForAllHosts: async (options) => { - // Why: startup renders an all-host sidebar; replacing groups with only the active host leaves other hosts' repos visible but ungrouped. - const applyCatalog = (catalog: FetchedProjectGroupCatalog, fence: HostCatalogFence): void => { - if (!isHostCatalogFenceCurrent(get, fence)) { - return - } - set((s) => { - if (!isHostCatalogFenceCurrent(get, fence)) { - return s - } - const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, s.projectGroups) - return { - projectGroups, - ...(arrayElementsUnchanged(projectGroups, s.projectGroups) - ? {} - : { folderWorkspacePathStatuses: {} }) - } - }) - } - - try { - const target = { kind: 'local' as const } - const fence = claimHostCatalogFence(get, 'project-groups', target) - applyCatalog(await fetchProjectGroupCatalogForTarget(target), fence) - } catch (err) { - console.error('Failed to fetch local project groups for all-host load:', err) - } - if (options?.remoteHosts === 'skip') { - return - } - - const environments = await listRuntimeEnvironmentsForAllHostLoad() - await Promise.all( - environments.map(async (environment) => { - const target = { - kind: 'environment' as const, - environmentId: environment.id - } - const fence = claimHostCatalogFence(get, 'project-groups', target) - try { - applyCatalog(await fetchProjectGroupCatalogForTarget(target), fence) - } catch (err) { - console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) - } - }) - ) - }, - - fetchFolderWorkspaces: async (options) => { - try { - const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) - const fence = claimHostCatalogFence(get, 'folder-workspaces', target) - const catalog = await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups) - if (!isHostCatalogFenceCurrent(get, fence)) { - return - } - set((current) => { - if (!isHostCatalogFenceCurrent(get, fence)) { - return current - } - folderWorkspaceUpdates.recordCatalogReplacement( - getFolderWorkspaceCatalogReplacementIdentities( - catalog, - current.folderWorkspaces, - current.projectGroups - ) - ) - const { folderWorkspaces } = mergeFetchedFolderWorkspaceCatalog( - catalog, - current.folderWorkspaces, - current.projectGroups - ) - return { - folderWorkspaces, - ...(arrayElementsUnchanged(folderWorkspaces, current.folderWorkspaces) - ? {} - : { folderWorkspacePathStatuses: {} }) - } - }) - } catch (err) { - console.error('Failed to fetch folder workspaces:', err) - } - }, - - fetchFolderWorkspacesForAllHosts: async (options) => { - const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) - // Why: folder workspaces are owned through their project groups; fetch groups first, then merge each host's folder slice. - const applyCatalog = ( - catalog: FetchedFolderWorkspaceCatalog, - fence: HostCatalogFence - ): void => { - if (!isHostCatalogFenceCurrent(get, fence)) { - return - } - set((current) => { - if (!isHostCatalogFenceCurrent(get, fence)) { - return current - } - folderWorkspaceUpdates.recordCatalogReplacement( - getFolderWorkspaceCatalogReplacementIdentities( - catalog, - current.folderWorkspaces, - current.projectGroups - ) - ) - const { folderWorkspaces } = mergeFetchedFolderWorkspaceCatalog( - catalog, - current.folderWorkspaces, - current.projectGroups - ) - return { - folderWorkspaces, - ...(arrayElementsUnchanged(folderWorkspaces, current.folderWorkspaces) - ? {} - : { folderWorkspacePathStatuses: {} }) - } - }) - } - - let failed = false - try { - const target = { kind: 'local' as const } - const fence = claimHostCatalogFence(get, 'folder-workspaces', target) - applyCatalog(await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups), fence) - } catch (err) { - failed = true - console.error('Failed to fetch local folder workspaces for all-host load:', err) - } - if (options?.remoteHosts === 'skip') { - return - } - - const environments = await listRuntimeEnvironmentsForAllHostLoad() - await Promise.all( - environments.map(async (environment) => { - const target = { - kind: 'environment' as const, - environmentId: environment.id - } - const fence = claimHostCatalogFence(get, 'folder-workspaces', target) - try { - applyCatalog( - await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups), - fence - ) - } catch (err) { - failed = true - console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err) - } - }) - ) - if (!failed) { - set((s) => ({ - restoredRuntimeHostIdByWorkspaceSessionKey: clearRestoredFolderWorkspaceSessionOwners( - s.restoredRuntimeHostIdByWorkspaceSessionKey, - s - ) - })) - } - }, - - getFolderWorkspacePathStatusCacheKey: (request, options) => - `${getRuntimeTargetCachePrefix( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - )}:${getFolderWorkspacePathStatusScopeKey(request)}`, - - getFreshFolderWorkspacePathStatus: (request, options) => { - const state = get() - const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options) - const cached = state.folderWorkspacePathStatuses[cacheKey] - const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request) - return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot }) - }, - - fetchFolderWorkspacePathStatus: async (request, options) => { - const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options) - const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request) - const cached = get().folderWorkspacePathStatuses[cacheKey] - const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({ - entry: cached, - requestSnapshot - }) - if (!options?.force && freshCachedStatus) { - return freshCachedStatus - } - try { - const target = getActiveRuntimeTarget( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - ) - const status = - target.kind === 'local' - ? await window.api.folderWorkspaces.getPathStatus(request) - : ( - await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>( - target, - 'folderWorkspace.getPathStatus', - request, - { timeoutMs: 15_000 } - ) - ).status - set((state) => ({ - folderWorkspacePathStatuses: - requestSnapshot !== null && - getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot - ? { - ...state.folderWorkspacePathStatuses, - [cacheKey]: { status, checkedAt: Date.now(), requestSnapshot } - } - : state.folderWorkspacePathStatuses - })) - return status - } catch (err) { - console.error('Failed to fetch folder workspace path status:', err) - return null - } - }, - - scanNestedRepos: async (path, connectionId, controls) => { - try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, controls?.runtimeEnvironmentId) - ) - if (target.kind === 'local') { - const unsubscribe = - controls?.scanId && controls.onProgress - ? window.api.projectGroups.onNestedScanProgress(({ scanId, scan }) => { - if (scanId === controls.scanId) { - controls.onProgress?.(normalizeNestedRepoScanResult(scan)) - } - }) - : undefined - try { - return normalizeNestedRepoScanResult( - await window.api.projectGroups.scanNested({ - path, - connectionId, - scanId: controls?.scanId - }) - ) - } finally { - unsubscribe?.() - } - } - return normalizeNestedRepoScanResult( - await callRuntimeRpc( - target, - 'projectGroup.scanNested', - { path }, - // Why: older runtime servers can't stream or cancel scans; keep a bounded failure path for large folders. - { timeoutMs: 20_000 } - ) - ) - } catch (err) { - console.error('Failed to scan nested repos:', err) - return null - } - }, - - cancelNestedRepoScan: async (scanId, options) => { - try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) - if (target.kind !== 'local') { - return false - } - return await window.api.projectGroups.cancelNestedScan({ scanId }) - } catch (err) { - console.error('Failed to cancel nested repo scan:', err) - return false - } - }, - - importNestedRepos: async (args) => { - try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, args.runtimeEnvironmentId) - ) - const result = - target.kind === 'local' - ? await window.api.projectGroups.importNested(args) - : await callRuntimeRpc( - target, - 'projectGroup.importNested', - { - parentPath: args.parentPath, - groupName: args.groupName, - projectPaths: args.projectPaths, - scanId: args.scanId, - mode: args.mode - }, - { timeoutMs: 60_000 } - ) - const catalogOptions = - 'runtimeEnvironmentId' in args - ? { runtimeEnvironmentId: args.runtimeEnvironmentId } - : undefined - await get().fetchProjectGroups(catalogOptions) - await get().fetchFolderWorkspaces(catalogOptions) - await (args.runtimeEnvironmentId - ? get().fetchRuntimeEnvironmentRepos(args.runtimeEnvironmentId) - : get().fetchRepos(catalogOptions)) - set({ folderWorkspacePathStatuses: {} }) - return result - } catch (err) { - console.error('Failed to import nested repos:', err) - toast.error( - translate('auto.store.slices.repos.6d3318e813', 'Failed to import repositories'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - return null - } - }, - - createProjectGroup: async (name) => { - try { - const target = getActiveRuntimeTarget(get().settings) - const group = - target.kind === 'local' - ? await window.api.projectGroups.create({ - name, - createdFrom: 'manual' - }) - : ( - await callRuntimeRpc<{ group: ProjectGroup }>( - target, - 'projectGroup.create', - { name, createdFrom: 'manual' }, - { timeoutMs: 15_000 } - ) - ).group - const ownedGroup = projectGroupWithFetchedOwner(group, target) - set((s) => ({ - projectGroups: [...s.projectGroups, ownedGroup], - folderWorkspacePathStatuses: {} - })) - return ownedGroup - } catch (err) { - console.error('Failed to create project group:', err) - return null - } - }, - - createFolderWorkspace: async (args, options) => { - try { - // Why: a new folder has no owner yet, so creation follows the caller-selected path-status host. - const target = getActiveRuntimeTarget( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - ) - if ( - target.kind === 'environment' && - (args.linkedTask?.provider === 'jira' || args.linkedTaskSourceContext?.provider === 'jira') - ) { - await assertRuntimeEnvironmentCapability( - target.environmentId, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Jira' - ) - } - const workspace = - target.kind === 'local' - ? await window.api.folderWorkspaces.create(args) - : ( - await callRuntimeRpc<{ folderWorkspace: FolderWorkspace }>( - target, - 'folderWorkspace.create', - args, - { timeoutMs: 15_000 } - ) - ).folderWorkspace - const ownedWorkspace = folderWorkspaceWithFetchedOwner(workspace, target, get().projectGroups) - set((s) => ({ - folderWorkspaces: [ownedWorkspace, ...s.folderWorkspaces], - folderWorkspacePathStatuses: {} - })) - return ownedWorkspace - } catch (err) { - console.error('Failed to create folder workspace:', err) - const { title, description } = formatFolderWorkspaceCreateError(err) - throw new Error(`${title}. ${description}`) - } - }, - - updateFolderWorkspace: async (folderWorkspaceId, updates, options) => { - const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) - const state = get() - const executionHostId = - options?.executionHostId ?? - (state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) - ? (state.activeWorkspaceExecutionHostId ?? undefined) - : undefined) - if (!findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId)) { - return false - } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) - // Why: owner-scoped mutations must not follow whichever runtime happens to be focused. - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) - const ownerHostId = executionHostId ?? getRuntimeTargetHostId(target) - const updateIdentity = getFolderWorkspaceUpdateIdentity(ownerHostId, folderWorkspaceId) - // Why: same gate as folderWorkspace.create — an older paired runtime would drop the Jira link silently. - if ( - target.kind === 'environment' && - (updates.linkedTask?.provider === 'jira' || - updates.linkedTaskSourceContext?.provider === 'jira') - ) { - await assertRuntimeEnvironmentCapability( - target.environmentId, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Jira' - ) - } - const updateTicket = folderWorkspaceUpdates.begin( - updateIdentity, - Object.keys(updates) as FolderWorkspaceUpdateField[] - ) - try { - const updated = - target.kind === 'local' - ? await window.api.folderWorkspaces.update({ folderWorkspaceId, updates }) - : ( - await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( - target, - 'folderWorkspace.update', - { folderWorkspaceId, updates }, - { timeoutMs: 15_000 } - ) - ).folderWorkspace - if (!updated) { - await reconcileFailedFolderWorkspaceUpdate({ - target, - folderWorkspaceId, - updateIdentity, - ownerHostId, - ticket: updateTicket, - coordinator: folderWorkspaceUpdates, - set, - get - }) - return false - } - if (updates.diffComments !== undefined && updated.diffComments === undefined) { - // Why: older paired runtimes strip this optional field; reconcile instead of showing an unsaved note. - await reconcileFailedFolderWorkspaceUpdate({ - target, - folderWorkspaceId, - updateIdentity, - ownerHostId, - ticket: updateTicket, - coordinator: folderWorkspaceUpdates, - set, - get - }) - return false - } - const latestFields = folderWorkspaceUpdates.latestFields(updateIdentity, updateTicket) - const catalogChanged = folderWorkspaceUpdates.catalogChanged(updateIdentity, updateTicket) - if (latestFields.length > 0) { - set((s) => ({ - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace.id === folderWorkspaceId && - getFolderWorkspaceHostId(workspace, s.projectGroups) === ownerHostId - ? mergeFolderWorkspaceUpdateResponse(workspace, updated, latestFields, { - rejectOlderResponse: catalogChanged - }) - : workspace - ), - ...(folderWorkspaceUpdateInvalidatesPathStatus(latestFields) - ? { folderWorkspacePathStatuses: {} } - : {}) - })) - } - return true - } catch (err) { - console.error('Failed to update folder workspace:', err) - await reconcileFailedFolderWorkspaceUpdate({ - target, - folderWorkspaceId, - updateIdentity, - ownerHostId, - ticket: updateTicket, - coordinator: folderWorkspaceUpdates, - set, - get - }) - return false - } finally { - folderWorkspaceUpdates.finish(updateIdentity, updateTicket) - } - }, - - deleteFolderWorkspace: async (folderWorkspaceId, options) => { - const state = get() - const executionHostId = options?.executionHostId - if (!findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId)) { - return false - } - const ownerHostId = getExecutionHostIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) - try { - // Why: deletion targets the folder's owner; focus may be on a different host. - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) - const deleted = - target.kind === 'local' - ? await window.api.folderWorkspaces.delete({ folderWorkspaceId }) - : ( - await callRuntimeRpc<{ deleted: boolean }>( - target, - 'folderWorkspace.delete', - { folderWorkspaceId }, - { timeoutMs: 15_000 } - ) - ).deleted - if (!deleted) { - return false - } - const workspaceKey = folderWorkspaceKey(folderWorkspaceId) - set((s) => ({ - folderWorkspaces: s.folderWorkspaces.filter( - (workspace) => - workspace.id !== folderWorkspaceId || - getFolderWorkspaceHostId(workspace, s.projectGroups) !== ownerHostId - ), - folderWorkspacePathStatuses: {} - })) - if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { - get().purgeWorktreeTerminalState([workspaceKey]) - } - return true - } catch (err) { - console.error('Failed to delete folder workspace:', err) - return false - } - }, - - updateProjectGroup: async (groupId, updates, options) => { - try { - // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. - const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = getActiveRuntimeTarget( - settingsForProjectGroupOwner(get(), groupId, options?.hostId) - ) - const updated = - target.kind === 'local' - ? await window.api.projectGroups.update({ groupId, updates }) - : ( - await callRuntimeRpc<{ group: ProjectGroup | null }>( - target, - 'projectGroup.update', - { groupId, updates }, - { timeoutMs: 15_000 } - ) - ).group - if (!updated) { - return false - } - const ownedGroup = projectGroupWithFetchedOwner(updated, target) - set((s) => ({ - projectGroups: s.projectGroups.map((group) => - projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group - ), - folderWorkspacePathStatuses: {} - })) - return true - } catch (err) { - console.error('Failed to update project group:', err) - return false - } - }, - - deleteProjectGroup: async (groupId, options) => { - try { - // Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere. - const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = getActiveRuntimeTarget( - settingsForProjectGroupOwner(get(), groupId, options?.hostId) - ) - const deleted = - target.kind === 'local' - ? await window.api.projectGroups.delete({ groupId }) - : ( - await callRuntimeRpc<{ deleted: boolean }>( - target, - 'projectGroup.delete', - { groupId }, - { timeoutMs: 15_000 } - ) - ).deleted - if (!deleted) { - return false - } - set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId)) - return true - } catch (err) { - console.error('Failed to delete project group:', err) - return false - } - }, - - deleteProjectGroupWithContainedProjects: async (groupId, options) => { - const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options.hostId) - const targets = selectProjectGroupRemovalTargets( - get().projectGroups, - get().repos, - groupId, - ownerHostId - ) - const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : [] - if (!targets.groupExists) { - return { - status: 'missing-group', - groupId, - requestedProjectIds, - removedProjectIds: [], - failedProjectRemovals: [] - } - } - - const deleted = await get().deleteProjectGroup(groupId, { - hostId: ownerHostId ?? undefined - }) - if (!deleted) { - return { - status: 'group-delete-failed', - groupId, - requestedProjectIds, - removedProjectIds: [], - failedProjectRemovals: [] - } - } - - if (!options.removeContainedProjects) { - return { - status: 'deleted-group', - groupId, - requestedProjectIds, - removedProjectIds: [], - failedProjectRemovals: [] - } - } - - const removedProjectIds: string[] = [] - const failedProjectRemovals: ProjectRemovalFailure[] = [] - // Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows), - // so each project is removed on its own host rather than on the group's. - const findOwnedProjects = (projectId: string): Repo[] => - get().repos.filter( - (repo) => - repo.id === projectId && - (!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo))) - ) - for (const projectId of targets.projectIds) { - const ownedProjects = findOwnedProjects(projectId) - const projectHostId = - ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined - try { - if (ownedProjects.length > 0) { - await get().removeProject(projectId, { hostId: projectHostId }) - } - } catch (err) { - console.error('Failed to remove contained project:', err) - } - const stillExists = findOwnedProjects(projectId).length > 0 - if (stillExists) { - failedProjectRemovals.push({ - projectId, - reason: 'Project remained in Orca after removeProject completed.' - }) - } else { - removedProjectIds.push(projectId) - } - } - - return { - status: 'deleted-group', - groupId, - requestedProjectIds, - removedProjectIds, - failedProjectRemovals - } - }, - - moveProjectToGroup: async (projectId, groupId, order) => { - try { - if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { - return false - } - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) - const moved = - target.kind === 'local' - ? await window.api.projectGroups.moveProject({ - projectId, - groupId, - order - }) - : ( - await callRuntimeRpc<{ repo: Repo | null }>( - target, - 'projectGroup.moveProject', - { repo: projectId, groupId, order }, - { timeoutMs: 15_000 } - ) - ).repo - if (!moved) { - return false - } - const ownedMoved = repoWithFetchedOwner(moved, target) - const movedHostId = getRepoExecutionHostId(ownedMoved) - set((s) => { - const nextRepos = s.repos.map((repo) => - repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo - ) - return { - repos: nextRepos, - ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, - nextRepos, - hostId: movedHostId - }), - folderWorkspacePathStatuses: {} - } - }) - return true - } catch (err) { - console.error('Failed to move repo to group:', err) - return false - } - }, - - addRepoPath: async (path, kind = 'git', options) => { - try { - const target = getActiveRuntimeTarget(getAddRepoPathRouteSettings(options, get().settings)) - let repo: Repo - try { - if (target.kind === 'local') { - const result = await window.api.repos.add({ path, kind }) - if ('error' in result) { - throw new Error(result.error) - } - repo = result.repo - } else { - repo = ( - await callRuntimeRpc<{ repo: Repo }>( - target, - 'repo.add', - { path, kind }, - { timeoutMs: 15_000 } - ) - ).repo - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (kind !== 'git' || !message.includes('Not a valid git repository')) { - throw err - } - if (target.kind !== 'local') { - const status = await fetchRuntimeAddProjectPathStatus({ target, path }) - if (status?.exists !== true) { - const hostName = getRuntimeEnvironmentDisplayName(get(), target.environmentId) - toast.error( - translate( - 'auto.store.slices.repos.3be0f7df04', - 'Cannot open folder on selected runtime' - ), - { - description: translate( - 'auto.store.slices.repos.15cf5319ec', - '{{path}} was checked on {{hostName}}, but that host did not report a usable folder.', - { path, hostName } - ), - duration: ERROR_TOAST_DURATION - } - ) - return null - } - } - // Why: folder mode is a capability downgrade (no worktrees/SCM/PRs/checks), so confirm via dialog rather than silently falling back. - const { openModal } = get() - openModal('confirm-non-git-folder', { - folderPath: path, - ...(target.kind === 'environment' ? { runtimeEnvironmentId: target.environmentId } : {}) - }) - return null - } - repo = repoWithFetchedOwner(repo, target) - const repoIdentity = getRepoHostIdentity(repo) - const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) - if (alreadyAdded) { - get().clearOrcaHookTrustForRepo(repo.id) - } - set((s) => { - if (s.repos.some((r) => getRepoHostIdentity(r) === repoIdentity)) { - return s - } - const nextRepos = [...s.repos, repo] - const hostId = getRepoExecutionHostId(repo) - return { - repos: nextRepos, - ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, - nextRepos, - hostId - }), - folderWorkspacePathStatuses: {} - } - }) - if (alreadyAdded) { - toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), { - description: repo.displayName - }) - } else { - toast.success( - isGitRepoKind(repo) - ? translate('auto.store.slices.repos.8bb3ad7935', 'Project added') - : translate('auto.store.slices.repos.90d129b48b', 'Folder added'), - { - description: repo.displayName - } - ) - // Why: the cross-profile advisory applies to SSH-added projects too; the presence lookup already keys on connection/host. - await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) - } - return repo - } catch (err) { - console.error('Failed to add project:', err) - const message = err instanceof Error ? err.message : String(err) - const duration = ERROR_TOAST_DURATION - toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { - description: message, - duration - }) - return null - } - }, - - setupProjectExistingFolder: async (args) => { - try { - const target = getProjectSetupRuntimeTarget(args.hostId) - await assertProjectHostSetupMutationRuntimeCapabilities(target) - const projectProviderIdentity = - args.projectProviderIdentity ?? - get().projects.find((project) => project.id === args.projectId)?.providerIdentity - // Why: the target host may not have a project record yet; carry the selected source-host identity across the boundary. - const setupArgs = projectProviderIdentity ? { ...args, projectProviderIdentity } : args - const result = - target.kind === 'local' - ? await window.api.projects.setupExistingFolder(setupArgs) - : ( - await callRuntimeRpc<{ result: ProjectHostSetupResult }>( - target, - 'projectHostSetup.setupExistingFolder', - setupArgs, - { timeoutMs: 15_000 } - ) - ).result - const repo = repoWithFetchedOwner(result.repo, target) - const repoHostId = getRepoExecutionHostId(repo) - const setup = setupWithFetchedOwner(result.setup, target) - set((s) => { - const nextRepos = s.repos.some((entry) => - repoMatchesHostIdentity(entry, repo.id, repoHostId) - ) - ? s.repos.map((entry) => - repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry - ) - : [...s.repos, repo] - const nextProjects = s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project] - const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) - ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) - : [...s.projectHostSetups, setup] - return { - repos: nextRepos, - projects: nextProjects, - projectHostSetups: nextSetups - } - }) - toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { - description: repo.displayName - }) - return { ...result, repo, setup } - } catch (err) { - console.error('Failed to set up project on host:', err) - const message = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { - description: message, - duration: ERROR_TOAST_DURATION - }) - return null - } - }, - - createProjectHostSetup: async (args) => { - try { - const target = getProjectSetupRuntimeTarget(args.hostId) - await assertProjectHostSetupMutationRuntimeCapabilities(target) - const result = - target.kind === 'local' - ? await window.api.projects.createHostSetup(args) - : ( - await callRuntimeRpc<{ result: ProjectHostSetupCreateResult }>( - target, - 'projectHostSetup.create', - args, - { timeoutMs: 15_000 } - ) - ).result - const setup = setupWithFetchedOwner(result.setup, target) - set((s) => ({ - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], - projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) - ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) - : [...s.projectHostSetups, setup] - })) - return { project: result.project, setup } - } catch (err) { - console.error('Failed to create project host setup:', err) - const message = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { - description: message, - duration: ERROR_TOAST_DURATION - }) - return null - } - }, - - updateProjectHostSetup: async (args) => { - try { - const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) - const target = currentSetup - ? getProjectSetupRuntimeTarget(currentSetup.hostId) - : { kind: 'local' as const } - await assertProjectHostSetupMutationRuntimeCapabilities(target) - const result = - target.kind === 'local' - ? await window.api.projects.updateHostSetup(args) - : ( - await callRuntimeRpc<{ result: ProjectHostSetupUpdateResult }>( - target, - 'projectHostSetup.update', - args, - { timeoutMs: 15_000 } - ) - ).result - const setup = setupWithFetchedOwner(result.setup, target) - const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined - const repoHostId = repo ? getRepoExecutionHostId(repo) : null - set((s) => ({ - repos: repo - ? s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId!)) - ? s.repos.map((entry) => - repoMatchesHostIdentity(entry, repo.id, repoHostId!) ? repo : entry - ) - : [...s.repos, repo] - : s.repos, - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], - projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) - ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) - : [...s.projectHostSetups, setup] - })) - return { ...result, repo, setup } - } catch (err) { - console.error('Failed to update project host setup:', err) - const message = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { - description: message, - duration: ERROR_TOAST_DURATION - }) - return null - } - }, - - deleteProjectHostSetup: async (args) => { - try { - const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) - const target = currentSetup - ? getProjectSetupRuntimeTarget(currentSetup.hostId) - : { kind: 'local' as const } - await assertProjectHostSetupMutationRuntimeCapabilities(target) - const result = - target.kind === 'local' - ? await window.api.projects.deleteHostSetup(args) - : ( - await callRuntimeRpc<{ result: ProjectHostSetupDeleteResult }>( - target, - 'projectHostSetup.delete', - args, - { timeoutMs: 15_000 } - ) - ).result - const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined - const repoHostId = repo ? getRepoExecutionHostId(repo) : null - set((s) => { - const projectHostSetups = s.projectHostSetups.filter( - (setup) => setup.id !== result.setup.id - ) - const repos = - repo && repoHostId - ? s.repos.filter((entry) => !repoMatchesHostIdentity(entry, repo.id, repoHostId)) - : s.repos - const projects = - repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) - ? s.projects.filter((project) => project.id !== result.project.id) - : s.projects - const survivingRepoIds = new Set(repos.map((r) => r.id)) - const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) - return { - repos, - projects, - projectHostSetups, - ...omitSparsePresetsForRepos(s, removedRepoIds) - } - }) - return { ...result, repo } - } catch (err) { - console.error('Failed to delete project host setup:', err) - const message = err instanceof Error ? err.message : String(err) - toast.error( - translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), - { - description: message, - duration: ERROR_TOAST_DURATION - } - ) - return null - } - }, - - setupProjectClone: async (args) => { - try { - const parsedHost = parseExecutionHostId(args.hostId) - const target = getProjectSetupRuntimeTarget(args.hostId) - if (parsedHost?.kind !== 'ssh') { - await assertProjectHostSetupMutationRuntimeCapabilities(target) - } - const repo = - parsedHost?.kind === 'ssh' - ? await window.api.repos.cloneRemote({ - connectionId: parsedHost.targetId, - url: args.url, - destination: args.destination - }) - : target.kind === 'local' - ? await window.api.repos.clone({ - url: args.url, - destination: args.destination - }) - : ( - await callRuntimeRpc<{ repo: Repo }>( - target, - 'repo.clone', - { - url: args.url, - destination: args.destination - }, - { timeoutMs: 10 * 60_000 } - ) - ).repo - return await get().setupProjectExistingFolder({ - projectId: args.projectId, - hostId: args.hostId, - path: repo.path, - kind: 'git', - displayName: args.displayName, - setupMethod: 'cloned' - }) - } catch (err) { - console.error('Failed to clone project on host:', err) - const message = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.repos.c6e022ddfc', 'Failed to add project'), { - description: message, - duration: ERROR_TOAST_DURATION - }) - return null - } - }, - - addRepo: async () => { - const target = getActiveRuntimeTarget(get().settings) - if (target.kind !== 'local') { - // Why: OS folder pickers return client-local paths; remote environments need an explicit host path (Add Project dialog). - toast.error( - translate( - 'auto.store.slices.repos.e649269645', - 'Use Add Project to enter a path on the selected host.' - ) - ) - return null - } - const path = await window.api.repos.pickFolder() - if (!path) { - return null - } - return get().addRepoPath(path) - }, - - addNonGitFolder: async (path, options) => { - try { - const hadProjectBeforeAdd = get().repos.length > 0 - const repo = await get().addRepoPath(path, 'folder', options) - if (!repo) { - return null - } - await markOnboardingProjectAdded('addedFolder') - // Why: focus the new folder so the add is visible; lazy-import worktree-activation to avoid a circular module load (it imports the store root). - const executionHostId = - options?.runtimeEnvironmentId === undefined - ? undefined - : options.runtimeEnvironmentId - ? toRuntimeExecutionHostId(options.runtimeEnvironmentId) - : LOCAL_EXECUTION_HOST_ID - await get().fetchWorktrees(repo.id, executionHostId ? { executionHostId } : undefined) - const folderWorktree = get().worktreesByRepo[repo.id]?.find( - (worktree) => executionHostId === undefined || worktree.hostId === executionHostId - ) - if (folderWorktree) { - const { activateAndRevealWorktree } = await import('../../lib/worktree-activation') - const onboarding = await window.api.onboarding.get().catch(() => null) - // Why: adding the first folder from Landing skips onboarding's completeRepo hook; carry the default agent into the first terminal here. - const startup = buildDismissedOnboardingFolderAgentStartup( - get().settings, - onboarding, - hadProjectBeforeAdd, - isNativeChatTranscriptLocalReadable(repo.connectionId) - ) - activateAndRevealWorktree(folderWorktree.id, { - sidebarRevealBehavior: 'auto', - ...(executionHostId ? { executionHostId } : {}), - ...(startup ? { startup } : {}) - }) - } - return repo - } catch (err) { - console.error('Failed to add folder:', err) - const message = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.repos.b7e14472ae', 'Failed to add folder'), { - description: message, - duration: ERROR_TOAST_DURATION - }) - return null - } - }, - - removeProject: async (projectId, options) => { - try { - // Why: pass an explicit hostId so a duplicate id across hosts resolves to the intended row, not the focused-host fallback. - const ownerRepo = findRepoForHost(get().repos, projectId, { - settings: get().settings, - hostId: options?.hostId - }) - if (!ownerRepo) { - return - } - const ownerHostId = getRepoExecutionHostId(ownerRepo) - const runtimeSshTargetId = ownerRepo.connectionId - // Why: an SSH per-workspace-env's workspace is the repo's main worktree, so removal routes here; tear down its ephemeral runtime first so it doesn't leak. - if (runtimeSshTargetId && isRuntimeOwnedSshTargetId(runtimeSshTargetId)) { - const cleanup = await cleanupEphemeralVmRuntimesForDeleted({ - workspaceIds: getKnownRepoWorktreeIds(get(), projectId, ownerHostId), - runtimeOwnedSshTargetIds: [runtimeSshTargetId] - }) - if (cleanup.retainedSshTargetIds.includes(runtimeSshTargetId)) { - throw new Error('The cloud VM could not be destroyed. Retry cleanup before removing it.') - } - } - // Why: derive the target from the owner's settings (via options.hostId) so an SSH host removal never routes repo.rm to the focused runtime. - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, options?.hostId)) - // Why: repos:remove is id-only and would delete every host's row; scope local removal to the owning host so cross-host duplicates keep other rows. - const idExistsOnOtherHost = get().repos.some( - (repo) => repo.id === projectId && getRepoExecutionHostId(repo) !== ownerHostId - ) - try { - await (target.kind === 'local' - ? idExistsOnOtherHost - ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) - : window.api.repos.remove({ repoId: projectId }) - : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) - } catch (err) { - // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). - if (!hasRuntimeRpcErrorCode(err, 'repo_not_found')) { - throw err - } - } - - get().clearOrcaHookTrustForRepo(projectId) - const repoPath = get().repos.find((repo) => - repoMatchesHostIdentity(repo, projectId, ownerHostId) - )?.path - get().evictGitHubRepoCaches(projectId, repoPath) - const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index') - clearRepoSlugCacheEntry(projectId) - - // Kill PTYs for all worktrees belonging to this repo - const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) - // A raw id can be published by two hosts. Keep the purge host-scoped for - // those twins so the sibling's qualified visit recency survives. - const knownRepoWorktrees = [ - ...(get().worktreesByRepo[projectId] ?? []), - ...(get().detectedWorktreesByRepo[projectId]?.worktrees ?? []) - ] - const exactSiblingIds = new Set( - knownRepoWorktrees - .filter((worktree) => !worktreeBelongsToHost(worktree, ownerHostId)) - .map((worktree) => worktree.id) - ) - const purgeTargets = worktreeIds.map((id) => - exactSiblingIds.has(id) ? { id, hostId: ownerHostId } : id - ) - const localAgentContextProjectIds = - ownerHostId === LOCAL_EXECUTION_HOST_ID - ? [ - projectId, - ...(get().worktreesByRepo[projectId] ?? []) - .filter((worktree) => worktreeBelongsToHost(worktree, ownerHostId)) - .flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : [])) - ] - : [] - const killedTabIds = new Set() - if (target.kind === 'environment') { - await Promise.allSettled( - worktreeIds.map((worktreeId) => - callRuntimeRpc( - target, - 'terminal.stop', - { worktree: toRuntimeWorktreeSelector(worktreeId) }, - { timeoutMs: 15_000 } - ) - ) - ) - } - for (const wId of worktreeIds) { - const tabs = get().tabsByWorktree[wId] ?? [] - for (const tab of tabs) { - killedTabIds.add(tab.id) - for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) { - if (!ptyId.startsWith('remote:')) { - window.api.pty.kill(ptyId) - } - } - } - } - - // Why: use the canonical per-worktree purge to evict all worktree-scoped maps (hand-deletion leaked most); runs before the set() below so it still sees tabsByWorktree. - get().purgeWorktreeTerminalState(purgeTargets) - get().clearLocalDetectedAgentContextsForProjects(localAgentContextProjectIds) - - set((s) => { - const nextWorktrees = { ...s.worktreesByRepo } - const remainingWorktrees = (nextWorktrees[projectId] ?? []).filter( - (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) - ) - if (remainingWorktrees.length > 0) { - nextWorktrees[projectId] = remainingWorktrees - } else { - delete nextWorktrees[projectId] - } - const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo } - const detected = nextDetectedWorktrees[projectId] - if (detected) { - const remainingDetected = detected.worktrees.filter( - (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) - ) - if (remainingDetected.length > 0) { - nextDetectedWorktrees[projectId] = { ...detected, worktrees: remainingDetected } - } else { - delete nextDetectedWorktrees[projectId] - } - } - const nextTabs = { ...s.tabsByWorktree } - const nextLayouts = { ...s.terminalLayoutsByTabId } - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - for (const wId of worktreeIds) { - delete nextTabs[wId] - } - for (const tabId of killedTabIds) { - delete nextLayouts[tabId] - delete nextPtyIdsByTabId[tabId] - delete nextRuntimePaneTitlesByTabId[tabId] - } - // Why: editor state is worktree-scoped; clear the repo's open files + active-file tracking so orphans don't linger in the session save. - const worktreeIdSet = new Set(worktreeIds) - const removedVisitKeys = new Set( - worktreeIds.map((worktreeId) => getWorktreeVisitKey(worktreeId, ownerHostId)) - ) - const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId)) - const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - for (const wId of worktreeIds) { - delete nextActiveFileIdByWorktree[wId] - delete nextActiveTabTypeByWorktree[wId] - } - const activeFileCleared = s.activeFileId - ? s.openFiles.some((f) => f.id === s.activeFileId && worktreeIdSet.has(f.worktreeId)) - : false - const nextRepos = s.repos.filter((r) => !repoMatchesHostIdentity(r, projectId, ownerHostId)) - // Why: when no sibling host owns this id, drop every worktree timestamp (unhydrated SSH ones would otherwise never prune); else stay host-scoped. - const repoIdFullyRemoved = !nextRepos.some((r) => r.id === projectId) - let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId - for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { - const rawId = getWorktreeIdFromVisitKey(id) - if ( - (ownerHostId && removedVisitKeys.has(id)) || - (!ownerHostId && worktreeIdSet.has(rawId)) || - (repoIdFullyRemoved && getRepoIdFromWorktreeId(rawId) === projectId) - ) { - if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { - nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } - } - delete nextLastVisitedAtByWorktreeId[id] - } - } - const survivingRepoIds = new Set(nextRepos.map((r) => r.id)) - const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) - return { - repos: nextRepos, - // Why: drop removed repos' sparse-preset maps so they don't outlive the repo for the whole session. - ...omitSparsePresetsForRepos(s, removedRepoIds), - ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, - nextRepos, - hostId: ownerHostId - }), - activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, - filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), - worktreesByRepo: nextWorktrees, - detectedWorktreesByRepo: nextDetectedWorktrees, - tabsByWorktree: nextTabs, - ptyIdsByTabId: nextPtyIdsByTabId, - runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - terminalLayoutsByTabId: nextLayouts, - activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId, - openFiles: nextOpenFiles, - activeFileIdByWorktree: nextActiveFileIdByWorktree, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - activeFileId: activeFileCleared ? null : s.activeFileId, - activeTabType: activeFileCleared ? 'terminal' : s.activeTabType, - lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId, - folderWorkspacePathStatuses: {}, - sortEpoch: s.sortEpoch + 1, - // Why: removing the last repo must reset activeView + clear activeWorktreeId so App renders Landing, not an empty settings/terminal pane. - ...(nextRepos.length === 0 - ? { - activeView: 'terminal' as const, - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - activeRepoId: null - } - : {}) - } - }) - } catch (err) { - console.error('Failed to remove repo:', err) - // Why: bulk and background callers aggregate their own failures, so only opted-in single-project entry points toast (#11994). - if (options?.errorFeedback === 'toast') { - toast.error( - translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), - { - description: err instanceof Error ? err.message : String(err), - duration: ERROR_TOAST_DURATION - } - ) - } - } - }, - - updateProject: async (projectId, updates) => { - try { - const target = getProjectUpdateRuntimeTarget(get(), projectId) - const updatedProject = - target.kind === 'local' - ? await window.api.projects.update({ projectId, updates }) - : ( - await callRuntimeRpc<{ project: Project }>( - target, - 'project.update', - { projectId, updates }, - { timeoutMs: 15_000 } - ) - ).project - if (!updatedProject) { - return false - } - const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates - set((state) => ({ - projects: state.projects.map((project) => - project.id === projectId - ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) - : project - ), - folderWorkspacePathStatuses: {} - })) - if (runtimePreferenceChanged) { - get().clearLocalDetectedAgents() - notifyInstalledAgentSkillsChanged() - } - return true - } catch (err) { - console.error('Failed to update project:', err) - return false - } - }, - - updateRepo: async (projectId, updates, options) => { - const updateRepoChains = getRepoUpdateChains(get) - // Why: pass options.hostId so a duplicate repo id across hosts resolves to the intended row, not the settings-focused fallback. - const ownerRepo = findRepoForHost(get().repos, projectId, { - settings: get().settings, - hostId: options?.hostId - }) - if (!ownerRepo) { - return false - } - // Why: an explicit hostId is authoritative; route to that host's target rather than the currently-focused runtime. - const ownerHasExplicitHost = Boolean( - options?.hostId || ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() - ) - const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) - const ownerTarget = ownerHasExplicitHost - ? getProjectSetupRuntimeTarget(explicitOwnerHostId) - : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) - const ownerHostId = ownerHasExplicitHost - ? explicitOwnerHostId - : getRuntimeTargetHostId(ownerTarget) - const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) - const applyRepoUpdate = async () => { - try { - const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = ownerTarget - const updatedRepo = - target.kind === 'local' - ? await window.api.repos.update({ - repoId: projectId, - updates: sanitizedUpdates, - ...(ownerHasExplicitHost ? { hostId: ownerHostId } : {}) - }) - : ( - await callRuntimeRpc<{ repo: Repo }>( - target, - 'repo.update', - { repo: projectId, updates: sanitizedUpdates }, - { timeoutMs: 15_000 } - ) - ).repo - set((s) => { - const nextRepos = s.repos.map((r) => { - const matchesOwner = ownerHasExplicitHost - ? repoMatchesHostIdentity(r, projectId, ownerHostId) - : repoMatchesHostIdentity(r, projectId, ownerHostId) || r === ownerRepo - if (!matchesOwner) { - return r - } - if (updatedRepo) { - return repoWithFetchedOwner(updatedRepo, target) - } - let mergedRepo: Repo = r - const { - sourceControlAi, - externalWorktreeDiscoverySuppressedAt, - externalWorktreeVisibility, - agentWorktreeVisibility, - ...updatesWithoutClearSentinels - } = sanitizedUpdates - mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels } - if (sourceControlAi === null) { - const { sourceControlAi: _sourceControlAi, ...repoWithoutSourceControlAi } = - mergedRepo - mergedRepo = repoWithoutSourceControlAi - } else if (sourceControlAi !== undefined) { - mergedRepo = { ...mergedRepo, sourceControlAi } - } - if (externalWorktreeVisibility === null) { - const { externalWorktreeVisibility: _visibility, ...repoWithoutVisibility } = - mergedRepo - mergedRepo = { ...repoWithoutVisibility, externalWorktreeVisibilityLegacy: false } - } else if (externalWorktreeVisibility !== undefined) { - mergedRepo = { ...mergedRepo, externalWorktreeVisibility } - } - if (agentWorktreeVisibility === null) { - const { agentWorktreeVisibility: _agentVisibility, ...repoWithoutAgentVisibility } = - mergedRepo - mergedRepo = repoWithoutAgentVisibility - } else if (agentWorktreeVisibility !== undefined) { - mergedRepo = { ...mergedRepo, agentWorktreeVisibility } - } - if (externalWorktreeDiscoverySuppressedAt === null) { - const { - externalWorktreeDiscoverySuppressedAt: _suppressedAt, - ...repoWithoutSuppression - } = mergedRepo - mergedRepo = repoWithoutSuppression - } else if (externalWorktreeDiscoverySuppressedAt !== undefined) { - mergedRepo = { ...mergedRepo, externalWorktreeDiscoverySuppressedAt } - } - return mergedRepo - }) - return { - repos: nextRepos, - ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, - nextRepos, - hostId: ownerHostId - }), - folderWorkspacePathStatuses: {} - } - }) - return true - } catch (err) { - console.error('Failed to update repo:', err) - return false - } - } - const previous = updateRepoChains.get(updateChainKey) - // Why: settings persist as full nested values, so preserve per-repo call order — a slower response mustn't overwrite newer state. - const next = previous - ? previous.catch(() => undefined).then(applyRepoUpdate) - : applyRepoUpdate() - updateRepoChains.set(updateChainKey, next) - const cleanup = () => { - if (updateRepoChains.get(updateChainKey) === next) { - updateRepoChains.delete(updateChainKey) - } - } - void next.then(cleanup, cleanup) - return next - }, - - setActiveRepo: (projectId) => set({ activeRepoId: projectId }), - - reorderRepos: async (orderedIds) => { - // Optimistically apply the new order for instant sidebar update; resync only if main rejects (racing add/remove). - const previous = get().repos - const remainingById = new Map() - for (const repo of previous) { - const existing = remainingById.get(repo.id) - if (existing) { - existing.repos.push(repo) - } else { - remainingById.set(repo.id, { repos: [repo], nextIndex: 0 }) - } - } - const next: Repo[] = [] - for (const id of orderedIds) { - const remaining = remainingById.get(id) - const repo = remaining?.repos[remaining.nextIndex] - if (remaining) { - remaining.nextIndex += 1 - } - if (repo) { - next.push(repo) - } - } - if (next.length !== previous.length) { - // Caller passed a non-permutation — refuse to apply locally. - return - } - const manualRepoOrder = getManualRepoOrder(next) - set({ - repos: next, - manualRepoOrder, - folderWorkspacePathStatuses: {} - }) - try { - // Why: each host persists only its own repos and rejects non-permutations; dispatch one per-host permutation per owner. - const groups = splitRepoReorderByHost(orderedIds, next, get().settings) - const [results] = await Promise.all([ - Promise.all( - groups.map(async (group) => { - const parsed = parseExecutionHostId(group.hostId) - const target = - parsed?.kind === 'runtime' - ? ({ kind: 'environment', environmentId: parsed.environmentId } as const) - : ({ kind: 'local' } as const) - return target.kind === 'local' - ? window.api.repos.reorderForHost({ - hostId: group.hostId, - orderedIds: group.orderedIds - }) - : callRuntimeRpc<{ status: 'applied' | 'rejected' }>( - target, - 'repo.reorder', - { orderedIds: group.orderedIds }, - { timeoutMs: 15_000 } - ) - }) - ), - // Why: servers only persist local permutations; the desktop profile owns cross-host order after a cold load. - window.api.ui.set({ manualRepoOrder }) - ]) - if (results.some((result) => result.status === 'rejected')) { - await get().fetchReposForAllHosts() - } - } catch (err) { - console.error('Failed to reorder repos:', err) - await get().fetchReposForAllHosts() - } - } -}) diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 214c6017eda..491634b253b 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -1,4 +1,4 @@ -import type { RepoSlice } from './slices/repos' +import type { RepoSlice } from './repos/repo-state' import type { SparsePresetsSlice } from './slices/sparse-presets' import type { WorktreeSlice } from './slices/worktrees' import type { TerminalSlice } from './terminals/terminal-state'