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:
Neil
2026-08-11 01:50:37 -07:00
committed by GitHub
co-authored by Orca
parent e8904cf7d2
commit c8ee48701b
7 changed files with 424 additions and 51 deletions
@@ -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({})
})
})
+127 -40
View File
@@ -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: {} })
}
})
}