mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(renderer): keep catalog array identity and path-status cache on no-op refetches (#13770)
* perf(renderer): keep catalog array identity and path-status cache on no-op refetches mergeByIdentity always allocated a fresh array, so project-group and folder-workspace refetches replaced state by reference even when nothing changed, refiring a forced folderWorkspace.getPathStatus sweep per row on every repos:changed. Unchanged merges now return the previous array, and the paired folderWorkspacePathStatuses resets are gated on the same check so a no-op refetch neither clears the cache nor needs to refill it. Real catalog changes clear and refire exactly as before. Co-authored-by: Orca <help@stably.ai> * refactor(renderer): make the catalog arrays readonly The identity guard can now return the caller's array, so a future in-place push or sort on state.projectGroups / state.folderWorkspaces would alias store state. Typing the merge helpers and the slice fields readonly removes both `as T[]` casts the guard introduced and adds none, matching what #13744 did for state.repos. Partial test fixtures cast the element rather than the array, so no `as unknown as` laundering is needed. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot'
|
||||
import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot'
|
||||
import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options'
|
||||
|
||||
@@ -133,9 +134,13 @@ describe('buildDashboardWorktreeLaunchOptions', () => {
|
||||
const options = buildDashboardWorktreeLaunchOptions(
|
||||
state({
|
||||
folderWorkspaces: [
|
||||
{ id: 'folder-1', projectGroupId: 'group-1', connectionId: 'ssh-folder' }
|
||||
] as LaunchState['folderWorkspaces'],
|
||||
projectGroups: [{ id: 'group-1' }] as LaunchState['projectGroups'],
|
||||
{
|
||||
id: 'folder-1',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'ssh-folder'
|
||||
} as FolderWorkspace
|
||||
],
|
||||
projectGroups: [{ id: 'group-1' } as ProjectGroup],
|
||||
remoteDetectedAgentIds: { 'ssh-folder': ['goose'] }
|
||||
}),
|
||||
[card({ repoId: 'folder-workspace:group-1', worktreeId })]
|
||||
|
||||
@@ -24,7 +24,7 @@ type RetainedAgentSnapshot = Map<string, { row: DashboardAgentRow; worktreeId: s
|
||||
type RetainedAgentsSyncInputs = {
|
||||
repos: readonly Repo[]
|
||||
worktreesByRepo: Record<string, Worktree[]>
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function paneKeyTabId(paneKey: string): string | null {
|
||||
function buildLiveTabIndex(args: {
|
||||
repos: readonly Repo[]
|
||||
worktreesByRepo: Record<string, Worktree[]>
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
}): {
|
||||
existingWorktreeIds: Set<string>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard
|
||||
import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { FolderWorkspace } from '../../../../shared/types'
|
||||
import {
|
||||
inspectRichMarkdownSourceOwningSlice,
|
||||
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
|
||||
@@ -309,7 +310,7 @@ describe('rich Markdown HTML superscript links', () => {
|
||||
|
||||
it('uses a folder workspace path as the citation source root', () => {
|
||||
const state = {
|
||||
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' }],
|
||||
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' } as FolderWorkspace],
|
||||
worktreesByRepo: {}
|
||||
} as Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
|
||||
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../../../shared/types'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget'
|
||||
|
||||
@@ -32,14 +32,14 @@ describe('getAgentDetectionTargetKeyForWorktree', () => {
|
||||
id: 'runtime-folder',
|
||||
projectGroupId: 'runtime-group',
|
||||
folderPath: '/workspace'
|
||||
}
|
||||
} as FolderWorkspace
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'runtime-group',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:owner-env'
|
||||
}
|
||||
} as ProjectGroup
|
||||
],
|
||||
repos,
|
||||
worktreesByRepo: {}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getProjectGroupSubtreeIds } from '../../../shared/project-groups'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
export type FolderWorkspaceConnectionState = {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
projectGroups: ProjectGroup[]
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import type { FolderWorkspacePathStatusCacheEntry } from './repos'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
const projectGroup: ProjectGroup = {
|
||||
id: 'group-1',
|
||||
name: 'Group 1',
|
||||
parentPath: '/parent',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const secondProjectGroup: ProjectGroup = {
|
||||
...projectGroup,
|
||||
id: 'group-2',
|
||||
name: 'Group 2',
|
||||
parentPath: '/parent-2',
|
||||
tabOrder: 1
|
||||
}
|
||||
|
||||
const folderWorkspace: FolderWorkspace = {
|
||||
id: 'folder-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Folder 1',
|
||||
folderPath: '/parent/folder-1',
|
||||
linkedTask: {
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 7,
|
||||
title: 'Issue 7',
|
||||
url: 'https://example.test/7'
|
||||
},
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const secondFolderWorkspace: FolderWorkspace = {
|
||||
...folderWorkspace,
|
||||
id: 'folder-2',
|
||||
name: 'Folder 2',
|
||||
folderPath: '/parent/folder-2',
|
||||
linkedTask: null,
|
||||
sortOrder: 1
|
||||
}
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo-1',
|
||||
displayName: 'Repo 1',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
|
||||
const cachedPathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry> = {
|
||||
'local:folder-workspace:folder-1': {
|
||||
status: { path: '/parent/folder-1', exists: false, reason: 'missing' },
|
||||
checkedAt: Date.now(),
|
||||
requestSnapshot: 'snapshot'
|
||||
}
|
||||
}
|
||||
|
||||
const projectGroupsList = vi.fn()
|
||||
const folderWorkspacesList = vi.fn()
|
||||
const reposList = vi.fn()
|
||||
const projectsList = vi.fn()
|
||||
const listHostSetups = vi.fn()
|
||||
|
||||
// Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated objects.
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
projectGroupsList.mockReset()
|
||||
folderWorkspacesList.mockReset()
|
||||
reposList.mockReset()
|
||||
projectsList.mockReset()
|
||||
listHostSetups.mockReset()
|
||||
projectGroupsList.mockImplementation(async () => [clone(projectGroup)])
|
||||
folderWorkspacesList.mockImplementation(async () => [clone(folderWorkspace)])
|
||||
reposList.mockImplementation(async () => [clone(repo)])
|
||||
projectsList.mockImplementation(async () => [])
|
||||
listHostSetups.mockImplementation(async () => [])
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
projectGroups: { list: projectGroupsList },
|
||||
folderWorkspaces: { list: folderWorkspacesList },
|
||||
repos: { list: reposList },
|
||||
projects: { list: projectsList, listHostSetups }
|
||||
},
|
||||
dispatchEvent: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
describe('catalog merge referential stability', () => {
|
||||
it('keeps the same projectGroups array when a refetch changes nothing', async () => {
|
||||
const store = createTestStore()
|
||||
|
||||
await store.getState().fetchProjectGroups()
|
||||
const first = store.getState().projectGroups
|
||||
expect(first).toEqual([{ ...projectGroup, executionHostId: 'local' }])
|
||||
|
||||
await store.getState().fetchProjectGroups()
|
||||
|
||||
expect(store.getState().projectGroups).toBe(first)
|
||||
})
|
||||
|
||||
it('keeps the same folderWorkspaces array (and entries) when a refetch changes nothing', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] })
|
||||
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
const first = store.getState().folderWorkspaces
|
||||
expect(first).toHaveLength(1)
|
||||
const firstEntry = first[0]
|
||||
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
|
||||
expect(store.getState().folderWorkspaces).toBe(first)
|
||||
expect(store.getState().folderWorkspaces[0]).toBe(firstEntry)
|
||||
})
|
||||
|
||||
it('appends new entries and replaces changed ones while keeping order', async () => {
|
||||
const store = createTestStore()
|
||||
|
||||
await store.getState().fetchProjectGroups()
|
||||
const first = store.getState().projectGroups
|
||||
|
||||
projectGroupsList.mockImplementation(async () => [
|
||||
clone({ ...projectGroup, name: 'Renamed' }),
|
||||
clone(secondProjectGroup)
|
||||
])
|
||||
await store.getState().fetchProjectGroups()
|
||||
|
||||
const merged = store.getState().projectGroups
|
||||
expect(merged).not.toBe(first)
|
||||
expect(merged).toEqual([
|
||||
{ ...projectGroup, name: 'Renamed', executionHostId: 'local' },
|
||||
{ ...secondProjectGroup, executionHostId: 'local' }
|
||||
])
|
||||
})
|
||||
|
||||
it('appends new folder workspaces without disturbing existing order', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] })
|
||||
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
const firstEntry = store.getState().folderWorkspaces[0]
|
||||
|
||||
folderWorkspacesList.mockImplementation(async () => [
|
||||
clone(secondFolderWorkspace),
|
||||
clone(folderWorkspace)
|
||||
])
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
|
||||
const merged = store.getState().folderWorkspaces
|
||||
expect(merged.map((workspace) => workspace.id)).toEqual(['folder-1', 'folder-2'])
|
||||
// Unchanged entries keep their reference even when the array changes.
|
||||
expect(merged[0]).toBe(firstEntry)
|
||||
})
|
||||
|
||||
it('drops entries that vanish from the fetched catalog', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] })
|
||||
|
||||
projectGroupsList.mockImplementation(async () => [clone(secondProjectGroup)])
|
||||
await store.getState().fetchProjectGroups()
|
||||
|
||||
expect(store.getState().projectGroups).toEqual([
|
||||
{ ...secondProjectGroup, executionHostId: 'local' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a nested linkedTask change as a change', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] })
|
||||
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
const first = store.getState().folderWorkspaces
|
||||
|
||||
folderWorkspacesList.mockImplementation(async () => [
|
||||
clone({
|
||||
...folderWorkspace,
|
||||
linkedTask: { ...folderWorkspace.linkedTask!, title: 'Issue 7 renamed' }
|
||||
})
|
||||
])
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
|
||||
expect(store.getState().folderWorkspaces).not.toBe(first)
|
||||
expect(store.getState().folderWorkspaces[0]?.linkedTask?.title).toBe('Issue 7 renamed')
|
||||
})
|
||||
})
|
||||
|
||||
// The sidebar burst that refills this cache only runs when these arrays change reference, so a
|
||||
// no-op fetch must not wipe it — otherwise stale-folder rows silently fail open forever.
|
||||
describe('folder path status cache retention across catalog fetches', () => {
|
||||
it('keeps cached path statuses when a project-group refetch changes nothing', async () => {
|
||||
const store = createTestStore()
|
||||
await store.getState().fetchProjectGroups()
|
||||
store.setState({ folderWorkspacePathStatuses: cachedPathStatuses })
|
||||
|
||||
await store.getState().fetchProjectGroups()
|
||||
|
||||
expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses)
|
||||
})
|
||||
|
||||
it('clears cached path statuses when the project-group catalog changes', async () => {
|
||||
const store = createTestStore()
|
||||
await store.getState().fetchProjectGroups()
|
||||
store.setState({ folderWorkspacePathStatuses: cachedPathStatuses })
|
||||
|
||||
projectGroupsList.mockImplementation(async () => [
|
||||
clone(projectGroup),
|
||||
clone(secondProjectGroup)
|
||||
])
|
||||
await store.getState().fetchProjectGroups()
|
||||
|
||||
expect(store.getState().folderWorkspacePathStatuses).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps cached path statuses when a folder-workspace refetch changes nothing', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] })
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
store.setState({ folderWorkspacePathStatuses: cachedPathStatuses })
|
||||
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
|
||||
expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses)
|
||||
})
|
||||
|
||||
it('clears cached path statuses when a folder workspace path changes', async () => {
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] })
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
store.setState({ folderWorkspacePathStatuses: cachedPathStatuses })
|
||||
|
||||
folderWorkspacesList.mockImplementation(async () => [
|
||||
clone({ ...folderWorkspace, folderPath: '/parent/folder-1-renamed' })
|
||||
])
|
||||
await store.getState().fetchFolderWorkspaces()
|
||||
|
||||
expect(store.getState().folderWorkspacePathStatuses).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps cached path statuses when a repo refetch changes nothing', async () => {
|
||||
const store = createTestStore()
|
||||
await store.getState().fetchRepos()
|
||||
store.setState({ folderWorkspacePathStatuses: cachedPathStatuses })
|
||||
|
||||
await store.getState().fetchRepos()
|
||||
|
||||
expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses)
|
||||
})
|
||||
|
||||
it('clears cached path statuses when the repo catalog changes', async () => {
|
||||
const store = createTestStore()
|
||||
await store.getState().fetchRepos()
|
||||
store.setState({ folderWorkspacePathStatuses: cachedPathStatuses })
|
||||
|
||||
reposList.mockImplementation(async () => [clone({ ...repo, path: '/repo-1-moved' })])
|
||||
await store.getState().fetchRepos()
|
||||
|
||||
expect(store.getState().folderWorkspacePathStatuses).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -859,24 +859,85 @@ function mergeFetchedProjectCompatibilityForHost({
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainCatalogObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
// Why: catalog fetches rebuild every entry from IPC, so identity alone never matches;
|
||||
// structural equality is what lets an unchanged refetch stay a no-op.
|
||||
function areCatalogEntriesEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return (
|
||||
Array.isArray(a) &&
|
||||
Array.isArray(b) &&
|
||||
a.length === b.length &&
|
||||
a.every((entry, index) => areCatalogEntriesEqual(entry, b[index]))
|
||||
)
|
||||
}
|
||||
if (!isPlainCatalogObject(a) || !isPlainCatalogObject(b)) {
|
||||
return false
|
||||
}
|
||||
const keys = Object.keys(a)
|
||||
if (keys.length !== Object.keys(b).length) {
|
||||
return false
|
||||
}
|
||||
return keys.every(
|
||||
(key) => Object.prototype.hasOwnProperty.call(b, key) && areCatalogEntriesEqual(a[key], b[key])
|
||||
)
|
||||
}
|
||||
|
||||
// Why: returning `base` unchanged keeps referential-equality selectors quiet, so a
|
||||
// `repos:changed` echo doesn't re-force folder path-status fetches for every row.
|
||||
function mergeByIdentity<T>(
|
||||
base: readonly T[],
|
||||
overlay: readonly T[],
|
||||
getIdentity: (entry: T) => string
|
||||
): T[] {
|
||||
): 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)
|
||||
} else {
|
||||
merged[index] = entry
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (areCatalogEntriesEqual(merged[index], entry)) {
|
||||
continue
|
||||
}
|
||||
merged[index] = entry
|
||||
changed = true
|
||||
}
|
||||
return merged
|
||||
return changed ? merged : base
|
||||
}
|
||||
|
||||
// Why: `preserved` keeps `previous`'s order and element refs, so an equal-length no-op
|
||||
// merge can hand the original store array straight back.
|
||||
function unchangedMergeSource<T>(
|
||||
previous: readonly T[],
|
||||
preserved: readonly T[],
|
||||
merged: readonly T[]
|
||||
): readonly T[] {
|
||||
return merged === preserved && preserved.length === previous.length ? previous : merged
|
||||
}
|
||||
|
||||
// Why: the sidebar effect watching these catalog arrays is the only thing that refills the
|
||||
// folder path-status cache, so a no-op refetch must not wipe it — nothing would repopulate it.
|
||||
function catalogRowsUnchanged<T>(next: readonly T[], previous: readonly T[]): boolean {
|
||||
return (
|
||||
next === previous ||
|
||||
(next.length === previous.length && next.every((row, index) => row === previous[index]))
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFetchedReposForHost(
|
||||
@@ -976,7 +1037,7 @@ function mergeFetchedProjectGroupsForHost(
|
||||
previous: readonly ProjectGroup[],
|
||||
fetched: ProjectGroup[],
|
||||
hostId: string
|
||||
): ProjectGroup[] {
|
||||
): readonly ProjectGroup[] {
|
||||
const fetchedIdentities = new Set(fetched.map(getProjectGroupHostIdentity))
|
||||
const preserved = previous.filter((group) => {
|
||||
const existingHostId = getProjectGroupHostId(group)
|
||||
@@ -985,7 +1046,11 @@ function mergeFetchedProjectGroupsForHost(
|
||||
fetchedIdentities.has(getProjectGroupHostIdentity(group))
|
||||
)
|
||||
})
|
||||
return mergeByIdentity(preserved, fetched, getProjectGroupHostIdentity)
|
||||
return unchangedMergeSource(
|
||||
previous,
|
||||
preserved,
|
||||
mergeByIdentity(preserved, fetched, getProjectGroupHostIdentity)
|
||||
)
|
||||
}
|
||||
|
||||
function getFolderWorkspaceHostId(
|
||||
@@ -1033,7 +1098,7 @@ function mergeFetchedFolderWorkspacesForHost({
|
||||
fetched: FolderWorkspace[]
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
hostId: string
|
||||
}): FolderWorkspace[] {
|
||||
}): readonly FolderWorkspace[] {
|
||||
const fetchedIdentities = new Set(
|
||||
fetched.map((workspace) => getFolderWorkspaceHostIdentity(workspace, projectGroups))
|
||||
)
|
||||
@@ -1044,8 +1109,12 @@ function mergeFetchedFolderWorkspacesForHost({
|
||||
fetchedIdentities.has(getFolderWorkspaceHostIdentity(workspace, projectGroups))
|
||||
)
|
||||
})
|
||||
return mergeByIdentity(preserved, fetched, (workspace) =>
|
||||
getFolderWorkspaceHostIdentity(workspace, projectGroups)
|
||||
return unchangedMergeSource(
|
||||
previous,
|
||||
preserved,
|
||||
mergeByIdentity(preserved, fetched, (workspace) =>
|
||||
getFolderWorkspaceHostIdentity(workspace, projectGroups)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1240,7 +1309,7 @@ async function fetchProjectGroupCatalogForTarget(
|
||||
function mergeFetchedProjectGroupCatalog(
|
||||
catalog: FetchedProjectGroupCatalog,
|
||||
currentProjectGroups: readonly ProjectGroup[]
|
||||
): { projectGroups: ProjectGroup[]; hostId: ReturnType<typeof getRuntimeTargetHostId> } {
|
||||
): { projectGroups: readonly ProjectGroup[]; hostId: ReturnType<typeof getRuntimeTargetHostId> } {
|
||||
return {
|
||||
projectGroups: mergeFetchedProjectGroupsForHost(
|
||||
currentProjectGroups,
|
||||
@@ -1293,7 +1362,7 @@ function mergeFetchedFolderWorkspaceCatalog(
|
||||
currentFolderWorkspaces: readonly FolderWorkspace[],
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
hostId: ReturnType<typeof getRuntimeTargetHostId>
|
||||
} {
|
||||
return {
|
||||
@@ -1623,8 +1692,8 @@ export type RepoSlice = {
|
||||
repos: readonly Repo[]
|
||||
projects: Project[]
|
||||
projectHostSetups: ProjectHostSetup[]
|
||||
projectGroups: ProjectGroup[]
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
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).
|
||||
@@ -1933,7 +2002,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
|
||||
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
|
||||
...mergedProjectCompatibility,
|
||||
folderWorkspacePathStatuses: {},
|
||||
...(catalogRowsUnchanged(prunedRepos, s.repos)
|
||||
? {}
|
||||
: { folderWorkspacePathStatuses: {} }),
|
||||
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
|
||||
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
|
||||
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
|
||||
@@ -2084,7 +2155,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
|
||||
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
|
||||
...mergedProjectCompatibility,
|
||||
folderWorkspacePathStatuses: {},
|
||||
...(catalogRowsUnchanged(finalizedRepos, s.repos)
|
||||
? {}
|
||||
: { folderWorkspacePathStatuses: {} }),
|
||||
activeRepoId: s.activeRepoId,
|
||||
filterRepoIds: s.filterRepoIds,
|
||||
setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds
|
||||
@@ -2166,15 +2239,18 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
if (!isHostCatalogFenceCurrent(get, fence)) {
|
||||
return
|
||||
}
|
||||
set((current) =>
|
||||
isHostCatalogFenceCurrent(get, fence)
|
||||
? {
|
||||
projectGroups: mergeFetchedProjectGroupCatalog(catalog, current.projectGroups)
|
||||
.projectGroups,
|
||||
folderWorkspacePathStatuses: {}
|
||||
}
|
||||
: current
|
||||
)
|
||||
set((current) => {
|
||||
if (!isHostCatalogFenceCurrent(get, fence)) {
|
||||
return current
|
||||
}
|
||||
const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, current.projectGroups)
|
||||
return {
|
||||
projectGroups,
|
||||
...(catalogRowsUnchanged(projectGroups, current.projectGroups)
|
||||
? {}
|
||||
: { folderWorkspacePathStatuses: {} })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project groups:', err)
|
||||
}
|
||||
@@ -2186,15 +2262,18 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
if (!isHostCatalogFenceCurrent(get, fence)) {
|
||||
return
|
||||
}
|
||||
set((s) =>
|
||||
isHostCatalogFenceCurrent(get, fence)
|
||||
? {
|
||||
projectGroups: mergeFetchedProjectGroupCatalog(catalog, s.projectGroups)
|
||||
.projectGroups,
|
||||
folderWorkspacePathStatuses: {}
|
||||
}
|
||||
: s
|
||||
)
|
||||
set((s) => {
|
||||
if (!isHostCatalogFenceCurrent(get, fence)) {
|
||||
return s
|
||||
}
|
||||
const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, s.projectGroups)
|
||||
return {
|
||||
projectGroups,
|
||||
...(catalogRowsUnchanged(projectGroups, s.projectGroups)
|
||||
? {}
|
||||
: { folderWorkspacePathStatuses: {} })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2252,7 +2331,12 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
current.folderWorkspaces,
|
||||
current.projectGroups
|
||||
)
|
||||
return { folderWorkspaces, folderWorkspacePathStatuses: {} }
|
||||
return {
|
||||
folderWorkspaces,
|
||||
...(catalogRowsUnchanged(folderWorkspaces, current.folderWorkspaces)
|
||||
? {}
|
||||
: { folderWorkspacePathStatuses: {} })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch folder workspaces:', err)
|
||||
@@ -2280,13 +2364,16 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
current.projectGroups
|
||||
)
|
||||
)
|
||||
const { folderWorkspaces } = mergeFetchedFolderWorkspaceCatalog(
|
||||
catalog,
|
||||
current.folderWorkspaces,
|
||||
current.projectGroups
|
||||
)
|
||||
return {
|
||||
folderWorkspaces: mergeFetchedFolderWorkspaceCatalog(
|
||||
catalog,
|
||||
current.folderWorkspaces,
|
||||
current.projectGroups
|
||||
).folderWorkspaces,
|
||||
folderWorkspacePathStatuses: {}
|
||||
folderWorkspaces,
|
||||
...(catalogRowsUnchanged(folderWorkspaces, current.folderWorkspaces)
|
||||
? {}
|
||||
: { folderWorkspacePathStatuses: {} })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user