From 4f0aaccc5fdf576eb720d59b92bfee2b7d2df72b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:16:37 -0800 Subject: [PATCH] fix(repos): a catalog answer sent before an add no longer drops the project that add made (#37384) --- .../repo-catalog-added-after-request.test.ts | 91 +++++++++++++++++++ .../src/store/repos/repo-catalog-identity.ts | 14 ++- .../src/store/repos/repo-catalog-merge.ts | 3 +- 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/store/repos/repo-catalog-added-after-request.test.ts diff --git a/src/renderer/src/store/repos/repo-catalog-added-after-request.test.ts b/src/renderer/src/store/repos/repo-catalog-added-after-request.test.ts new file mode 100644 index 00000000000..b16e42aa791 --- /dev/null +++ b/src/renderer/src/store/repos/repo-catalog-added-after-request.test.ts @@ -0,0 +1,91 @@ +// A catalog answer that left before an add must not drop the project that add just made. +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { getEmptyProjectPlaceholderRepoIds } from '../../../../shared/sidebar-list/empty-project-placeholder-repos' +import { mergeFetchedRepoCatalog, type FetchedRepoCatalog } from './repo-catalog-merge' + +const callRuntimeRpc = vi.hoisted(() => vi.fn()) +vi.mock('../../runtime/runtime-rpc-client', () => ({ callRuntimeRpc })) + +function repo(id: string, overrides: Partial): Repo { + return { + id, + path: `/home/me/${id}`, + displayName: id, + badgeColor: '#000', + addedAt: 1, + ...overrides + } +} + +function staleAnswer( + hostId: FetchedRepoCatalog['hostId'], + listed: readonly Repo[], + reposAtRequest: readonly Repo[] +): FetchedRepoCatalog { + return { + repos: listed, + projectHostSetupCompatibility: { projects: [], setups: [] }, + hostId, + reposAtRequest + } +} + +const hosts: [string, FetchedRepoCatalog['hostId'], Partial][] = [ + ['a WSL host', 'runtime:wsl-ubuntu', { executionHostId: 'runtime:wsl-ubuntu' }], + [ + 'an SSH target on a paired server', + 'runtime:server-1', + { executionHostId: 'runtime:server-1', connectionId: 'ssh-box' } + ], + ['this computer', 'local', {}] +] + +describe('a catalog answer that predates an add', () => { + afterEach(() => callRuntimeRpc.mockReset()) + + it.each(hosts)('keeps the project just added on %s', (_label, hostId, owner) => { + const old = repo('old', owner) + const added = repo('added', owner) + const merged = mergeFetchedRepoCatalog(staleAnswer(hostId, [old], [old]), [old, added]).repos + expect(merged.map((entry) => entry.id)).toEqual(['old', 'added']) + }) + + it.each(hosts)('still drops a project %s stopped listing', (_label, hostId, owner) => { + const old = repo('old', owner) + const gone = repo('gone', owner) + const merged = mergeFetchedRepoCatalog(staleAnswer(hostId, [old], [old, gone]), [ + old, + gone + ]).repos + expect(merged.map((entry) => entry.id)).toEqual(['old']) + }) + + it('a stopped WSL host still shows the added project, from store data alone', () => { + const owner: Partial = { executionHostId: 'runtime:wsl-ubuntu' } + const old = repo('old', owner) + const added = repo('added', owner) + const connect = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { connect, call: vi.fn() } } }) + try { + const repos = mergeFetchedRepoCatalog(staleAnswer('runtime:wsl-ubuntu', [old], [old]), [ + old, + added + ]).repos + // Its worktrees never loaded: the host stopped before they could be read. + const placeholders = getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos, + worktreesByRepo: {}, + visibleWorktrees: [], + filterRepoIds: [] + }) + expect(placeholders.has('added')).toBe(true) + // Showing the row reaches no host, so it cannot start the distro. + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts index 6961e33ef4b..3ac7a6d8be4 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -11,13 +11,21 @@ import { mergeByIdentity } from '../catalog-identity' export function mergeFetchedReposForHost( previous: readonly Repo[], fetched: readonly Repo[], - hostId: string + hostId: string, + /** The rows when the request went out; omitted, every row of the host is the answer's to drop. */ + reposAtRequest?: readonly Repo[] ): readonly Repo[] { const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched) const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoRowIdentity)) + const requested = reposAtRequest ? new Set(reposAtRequest.map(getRepoRowIdentity)) : null const preserved = previous.filter((repo) => { - const existingHostId = getRepoExecutionHostId(repo) - return existingHostId !== hostId || fetchedIdentities.has(getRepoRowIdentity(repo)) + const identity = getRepoRowIdentity(repo) + return ( + getRepoExecutionHostId(repo) !== hostId || + fetchedIdentities.has(identity) || + // Added after the request: an answer that predates it cannot drop it. + (requested !== null && !requested.has(identity)) + ) }) return reconcileFetchedRepos( previous, diff --git a/src/renderer/src/store/repos/repo-catalog-merge.ts b/src/renderer/src/store/repos/repo-catalog-merge.ts index 2ba27f626f8..fbf95747ef9 100644 --- a/src/renderer/src/store/repos/repo-catalog-merge.ts +++ b/src/renderer/src/store/repos/repo-catalog-merge.ts @@ -58,7 +58,8 @@ export function mergeFetchedRepoCatalog( const repos = mergeFetchedReposForHost( currentRepos, withoutPendingRuntimeRepoRemovals(catalog.repos, catalog.hostId), - catalog.hostId + catalog.hostId, + catalog.reposAtRequest ) return { repos,