From c8ee48701bdeca5bb39627da336273de050881f3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:50:37 -0700 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Orca --- .../dashboard-worktree-launch-options.test.ts | 11 +- .../components/dashboard/useRetainedAgents.ts | 4 +- ...ich-markdown-html-superscript-link.test.ts | 3 +- .../src/hooks/useAgentDetectionTarget.test.ts | 6 +- .../src/lib/folder-workspace-connection.ts | 4 +- .../repos-catalog-merge-identity.test.ts | 280 ++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 167 ++++++++--- 7 files changed, 424 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts index 42b63e3e993..a0aacdcc504 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -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 })] diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 1b88c32bb57..6fa1f94fda7 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -24,7 +24,7 @@ type RetainedAgentSnapshot = Map - folderWorkspaces: FolderWorkspace[] + folderWorkspaces: readonly FolderWorkspace[] tabsByWorktree: Record agentStatusByPaneKey: Record } @@ -40,7 +40,7 @@ function paneKeyTabId(paneKey: string): string | null { function buildLiveTabIndex(args: { repos: readonly Repo[] worktreesByRepo: Record - folderWorkspaces: FolderWorkspace[] + folderWorkspaces: readonly FolderWorkspace[] tabsByWorktree: Record }): { existingWorktreeIds: Set diff --git a/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts index a7dfc9a912e..90591cfdb17 100644 --- a/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-html-superscript-link.test.ts @@ -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 expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe( diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 1d1c4fa1417..22ad26ae016 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -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: {} diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index ee15c61e761..2c9376fc284 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -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[] } diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts new file mode 100644 index 00000000000..69f73e589b5 --- /dev/null +++ b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts @@ -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 = { + '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(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({}) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 3adbd0ff766..39b9edd20e1 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -859,24 +859,85 @@ function mergeFetchedProjectCompatibilityForHost({ } } +function isPlainCatalogObject(value: unknown): value is Record { + 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( 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( + 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(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 } { +): { projectGroups: readonly ProjectGroup[]; hostId: ReturnType } { return { projectGroups: mergeFetchedProjectGroupsForHost( currentProjectGroups, @@ -1293,7 +1362,7 @@ function mergeFetchedFolderWorkspaceCatalog( currentFolderWorkspaces: readonly FolderWorkspace[], projectGroups: readonly ProjectGroup[] ): { - folderWorkspaces: FolderWorkspace[] + folderWorkspaces: readonly FolderWorkspace[] hostId: ReturnType } { 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 activeRepoId: string | null // Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020). @@ -1933,7 +2002,9 @@ export const createRepoSlice: StateCreator = (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 = (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 = (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 = (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 = (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 = (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: {} }) } }) }