mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,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'
|
||||
|
||||
Reference in New Issue
Block a user