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
This commit is contained in:
Neil
2026-08-25 00:42:59 -07:00
committed by GitHub
parent a782935ab3
commit 5ebc4bb2bc
52 changed files with 5503 additions and 4110 deletions
-1
View File
@@ -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
@@ -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'
@@ -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 = {
@@ -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'
@@ -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<T>(
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<T>(
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<T>(next: readonly T[], previous: readonly T[]): boolean {
return (
next === previous ||
(next.length === previous.length && next.every((row, index) => row === previous[index]))
)
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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
}
}
}
}
@@ -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<AppState, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'sshConnectionStates'>,
request: FolderWorkspacePathStatusRequest
): string | null {
if (request.scope === 'path') {
const candidateRepos = state.repos.filter((repo) =>
isPathInsideOrEqual(request.path, repo.path)
)
const relevantConnectionIds = new Set<string>()
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<string>()
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)
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'fetchFolderWorkspaces' | 'fetchFolderWorkspacesForAllHosts'> {
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
)
}))
}
}
}
}
@@ -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<string> {
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, 'folderWorkspaces' | 'projectGroups'>
): 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<FetchedFolderWorkspaceCatalog> {
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<FolderWorkspaceUpdateField>
coordinator: FolderWorkspaceUpdateCoordinatorInstance
set: Parameters<StateCreator<AppState>>[0]
get: Parameters<StateCreator<AppState>>[1]
}): Promise<void> {
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)
}
}
@@ -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<FolderWorkspaceUpdateField>
export type RepoSliceGet = Parameters<StateCreator<AppState>>[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<FolderWorkspaceUpdateField>()
folderWorkspaceUpdateCoordinators.set(get, created)
return created
}
export function createFolderWorkspaceMutationActions(
set: Parameters<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'createFolderWorkspace' | 'updateFolderWorkspace' | 'deleteFolderWorkspace'> {
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
}
}
}
}
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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
}
@@ -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<string, number>>()
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
)
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'scanNestedRepos' | 'cancelNestedRepoScan' | 'importNestedRepos'> {
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<NestedRepoScanResult>(
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<ProjectGroupImportResult>(
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
}
}
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'fetchProjectGroups' | 'fetchProjectGroupsForAllHosts'> {
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)
}
})
)
}
}
}
@@ -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<FetchedProjectGroupCatalog> {
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
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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
}
}
}
}
@@ -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 }
}
@@ -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<RepoSlice, 'projectGroups' | 'folderWorkspaces' | 'repos'>,
groupId: string,
ownerHostId: ExecutionHostId | null
): Pick<RepoSlice, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'folderWorkspacePathStatuses'> {
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: {}
}
}
@@ -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<RepoSlice, 'projects' | 'projectHostSetups'> {
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>
): string[] {
return project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId))
}
export function getReposById(repos: readonly Repo[]): Map<string, Repo[]> {
const reposById = new Map<string, Repo[]>()
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<string, readonly Repo[]>,
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<string, readonly Repo[]>,
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<string>
): Project {
const sourceRepoIds = getCurrentSourceRepoIds(project, currentRepoIds)
return sourceRepoIds.length === project.sourceRepoIds.length
? project
: { ...project, sourceRepoIds }
}
export function getLocalHostRepoBadgeColor(
project: Project,
reposById: ReadonlyMap<string, readonly Repo[]>
): 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<string, readonly Repo[]>,
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<RepoSlice, 'projects' | 'projectHostSetups'>,
fetched: ProjectHostSetupProjection
): Pick<RepoSlice, 'projects' | 'projectHostSetups'> {
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
}
@@ -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<string> {
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<string> {
const hostIds = new Set<string>()
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<string, ProjectHostSetup[]> {
const setupsByProjectId = new Map<string, ProjectHostSetup[]>()
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<string, readonly Repo[]>
): 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<string, readonly Repo[]>,
resolveHostIds: (
project: Project,
setups: readonly ProjectHostSetup[],
repos: readonly Repo[]
) => Set<string>
): (project: Project) => ReadonlySet<string> {
const noSetups: readonly ProjectHostSetup[] = []
const hostIdsByProject = new Map<Project, ReadonlySet<string>>()
let setupsByProjectId: Map<string, ProjectHostSetup[]> | 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<string, readonly Repo[]>
): Map<string, readonly Repo[]> {
const restricted = new Map<string, readonly Repo[]>()
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<RepoSlice, 'projects' | 'projectHostSetups'>
fetched: Pick<RepoSlice, 'projects' | 'projectHostSetups'>
repos: readonly Repo[]
hostId: string
}): Pick<RepoSlice, 'projects' | 'projectHostSetups'> {
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
)
}
}
@@ -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<void> {
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<ProjectHostSetupProjection> {
try {
if (target.kind === 'local') {
const projectsApi = (
window.api as typeof window.api & {
projects?: {
list?: () => Promise<Project[]>
listHostSetups?: () => Promise<ProjectHostSetup[]>
}
}
).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<void> {
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
)
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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
}
}
}
}
@@ -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<void> {
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)
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'updateProject'> {
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
}
}
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'fetchReposForAllHosts'> {
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()
}
}
}
}
@@ -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<typeof getActiveRuntimeTarget>
): 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<AppState, 'repos' | 'settings'>,
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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<ReturnType<typeof getActiveRuntimeTarget>, { kind: 'environment' }>
path: string
}): Promise<FolderWorkspacePathStatus | null> {
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
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'addRepoPath' | 'addRepo' | 'addNonGitFolder'> {
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
}
}
}
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'recordSshRepoReadoptions' | 'fetchRepos' | 'awaitLocalRepoCatalogSettlement'> {
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)
}
}
@@ -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<LocalRepoCatalogFetchOutcome>
>()
export const latestRepoCatalogGenerationByHostByStore = new WeakMap<
() => AppState,
Map<string, number>
>()
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<LocalRepoCatalogFetchOutcome>((resolve) => {
settle = resolve
})
latestLocalRepoCatalogFetchByStore.set(get, settlement)
return settle
}
export async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise<void> {
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
}
@@ -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<string, string | null>()
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<RepoSlice, 'projects' | 'projectHostSetups'>
nextRepos: readonly Repo[]
hostId: string
}): Pick<RepoSlice, 'projects' | 'projectHostSetups'> {
return mergeFetchedProjectCompatibilityForHost({
previous,
fetched: projectCompatibilityFromRepos(nextRepos),
repos: nextRepos,
hostId
})
}
@@ -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<typeof getRuntimeTargetHostId>
}
export async function fetchRepoCatalogForTarget(
target: ReturnType<typeof getActiveRuntimeTarget>
): Promise<FetchedRepoCatalog> {
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<typeof getRuntimeTargetHostId>
} {
const repos = mergeFetchedReposForHost(currentRepos, catalog.repos, catalog.hostId)
return {
repos,
projectHostSetupCompatibility: catalog.projectHostSetupCompatibility,
hostId: catalog.hostId
}
}
export function reconcileSupersededSshRepos(
repos: readonly Repo[],
state: Pick<AppState, 'pendingSshRepoReadoptions'>
): 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<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'sortEpoch'>,
readoptions: readonly SshRepoReadoption[]
): Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'sortEpoch'> {
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<RepoSlice, 'projects' | 'projectHostSetups'> {
return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched)
}
export function filterTrustedOrcaHooksToValidRepos(
trust: AppState['trustedOrcaHooks'],
validRepoIds: Set<string>
): AppState['trustedOrcaHooks'] {
const next: AppState['trustedOrcaHooks'] = {}
for (const [repoId, entry] of Object.entries(trust)) {
if (validRepoIds.has(repoId)) {
next[repoId] = entry
}
}
return next
}
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'setActiveRepo' | 'reorderRepos'> {
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<string, { repos: Repo[]; nextIndex: number }>()
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()
}
}
}
}
@@ -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<string>()
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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'removeProject'> {
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<string>()
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
}
)
}
}
}
}
}
+258
View File
@@ -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<string, FolderWorkspacePathStatusCacheEntry>
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<void>
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
awaitLocalRepoCatalogSettlement: () => Promise<void>
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
fetchProjectGroups: (options?: RuntimeCatalogFetchOptions) => Promise<void>
fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
fetchFolderWorkspaces: (options?: RuntimeCatalogFetchOptions) => Promise<void>
fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
addRepo: () => Promise<Repo | null>
addRepoPath: (
path: string,
kind?: 'git' | 'folder',
options?: AddRepoPathRouteOptions
) => Promise<Repo | null>
setupProjectExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult | null>
createProjectHostSetup: (
args: ProjectHostSetupCreateArgs
) => Promise<ProjectHostSetupCreateResult | null>
updateProjectHostSetup: (
args: ProjectHostSetupUpdateArgs
) => Promise<ProjectHostSetupUpdateResult | null>
deleteProjectHostSetup: (
args: ProjectHostSetupDeleteArgs
) => Promise<ProjectHostSetupDeleteResult | null>
setupProjectClone: (args: ProjectHostSetupCloneArgs) => Promise<ProjectHostSetupResult | null>
addNonGitFolder: (path: string, options?: AddRepoPathRouteOptions) => Promise<Repo | null>
scanNestedRepos: (
path: string,
connectionId?: string,
controls?: NestedRepoScanControls
) => Promise<NestedRepoScanResult | null>
cancelNestedRepoScan: (scanId: string, options?: NestedRepoScanCancelOptions) => Promise<boolean>
importNestedRepos: (args: {
parentPath: string
groupName: string
projectPaths: string[]
connectionId?: string
scanId?: string
runtimeEnvironmentId?: string | null
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
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<FolderWorkspace | null>
getFolderWorkspacePathStatusCacheKey: (
request: FolderWorkspacePathStatusRequest,
options?: FolderWorkspacePathStatusRouteOptions
) => string
getFreshFolderWorkspacePathStatus: (
request: FolderWorkspacePathStatusRequest,
options?: FolderWorkspacePathStatusRouteOptions
) => FolderWorkspacePathStatus | null
fetchFolderWorkspacePathStatus: (
request: FolderWorkspacePathStatusRequest,
options?: { force?: boolean } & FolderWorkspacePathStatusRouteOptions
) => Promise<FolderWorkspacePathStatus | null>
updateFolderWorkspace: (
folderWorkspaceId: string,
updates: FolderWorkspaceUpdates,
options?: { executionHostId?: ExecutionHostId }
) => Promise<boolean>
deleteFolderWorkspace: (
folderWorkspaceId: string,
options?: { executionHostId?: ExecutionHostId }
) => Promise<boolean>
// 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<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>,
options?: { hostId?: ExecutionHostId }
) => Promise<boolean>
deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise<boolean>
deleteProjectGroupWithContainedProjects: (
groupId: string,
options: DeleteProjectGroupWithContainedProjectsOptions
) => Promise<DeleteProjectGroupWithContainedProjectsResult>
moveProjectToGroup: (
projectId: string,
groupId: string | null,
order?: number
) => Promise<boolean>
// 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<void>
updateProject: (projectId: string, updates: ProjectUpdate) => Promise<boolean>
// 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<boolean>
setActiveRepo: (projectId: string | null) => void
reorderRepos: (orderedIds: string[]) => Promise<void>
}
+216
View File
@@ -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<string, Promise<boolean>>>()
export function getRepoUpdateChains(get: () => AppState): Map<string, Promise<boolean>> {
let chains = updateRepoChainsByStore.get(get)
if (!chains) {
chains = new Map<string, Promise<boolean>>()
updateRepoChainsByStore.set(get, chains)
}
return chains
}
export function createRepoUpdateActions(
set: Parameters<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'updateRepo'> {
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
}
}
}
@@ -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<string, number>()
export function createRuntimeRepoCatalogActions(
set: Parameters<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'fetchRuntimeEnvironmentRepos'> {
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 []
}
}
}
}
@@ -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<void> }
>()
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 })
}
}
@@ -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 []
}
}
@@ -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
}
@@ -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({
@@ -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<Repo[]>()
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' }])
})
})
@@ -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<unknown>()
const { promise: repoListStarted, resolve: markRepoListStarted } = Promise.withResolvers<void>()
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<unknown>()
const { promise: repoListStarted, resolve: markRepoListStarted } = Promise.withResolvers<void>()
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<unknown>()
const { promise: repoListStarted, resolve: markRepoListStarted } = Promise.withResolvers<void>()
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
@@ -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 = {
@@ -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<void>()
const { promise: secondRemoval, resolve: resolveSecondRemoval } = Promise.withResolvers<void>()
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()
})
})
@@ -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<Record<string, unknown>>()
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<unknown>()
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<void>()
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
})
})
@@ -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({})
})
})
@@ -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 }
@@ -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,
@@ -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<Repo[]>()
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
@@ -108,6 +108,48 @@ describe('repo update serialization', () => {
)
})
it('runs same-ID updates on different hosts independently', async () => {
const slowLocalUpdate = deferred<void>()
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()
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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'