diff --git a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts index 867065b85bd..394e289ab3f 100644 --- a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts @@ -183,7 +183,8 @@ describe('repo settings on another host under a WSL move', () => { forgetTerminalTopology: vi.fn(), invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged: vi.fn() + notifyReposChanged: vi.fn(), + notifyWorktreesChanged: vi.fn() }) await expect(controller.setBaseRef('repo-1', 'origin/dev')).resolves.toBe(onHost) diff --git a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts index f5cb06e207d..634c38465e1 100644 --- a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts @@ -138,7 +138,8 @@ describe('RuntimeRepositorySettingsController under a WSL move', () => { forgetTerminalTopology: vi.fn(), invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), - notifyReposChanged + notifyReposChanged, + notifyWorktreesChanged: vi.fn() }) await expect(controller.update('repo-1', { displayName: 'renamed' })).rejects.toBeInstanceOf( diff --git a/src/main/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index 2b84cd5718d..a5e6b47ad49 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -50,6 +50,7 @@ export abstract class RuntimeConstructionServices { abstract listRepos(): Repo[] abstract invalidateResolvedWorktreeCache(): void abstract invalidateWorktreeScanCacheForRepo(repoId: string): void + abstract notifyWorktreesChanged(repoId: string, options?: { invalidateGitScans?: boolean }): void protected abstract notifyReposChanged(): void protected abstract isPtyKnownExited(ptyId: string): boolean diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index 06a767d84ee..d5d3463902f 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -333,13 +333,11 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW ) protected readonly repositorySettings = new RuntimeRepositorySettingsController({ - getStore: () => this.store, + ...this.repositoryCatalogDeps, resolveRepo: (selector) => this.resolveRepoSelector(selector), forgetTerminalTopology: (repoId) => this.terminalTopologyRevisionByRepoId.delete(repoId), stopProjectTerminals: (repo, options) => this.stopTerminalsForProject(repo, options), - invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), - invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId), - notifyReposChanged: () => this.notifyReposChanged() + notifyWorktreesChanged: (repoId, options) => this.notifyWorktreesChanged(repoId, options) }) protected readonly repositorySparsePresets = new RuntimeRepositorySparsePresets({ diff --git a/src/main/runtime/runtime-repo-update-worktree-refresh.test.ts b/src/main/runtime/runtime-repo-update-worktree-refresh.test.ts new file mode 100644 index 00000000000..2c539d6c2f4 --- /dev/null +++ b/src/main/runtime/runtime-repo-update-worktree-refresh.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import './orca-runtime-test-lifecycle.spec' +import { OrcaRuntimeService, listWorktrees } from './orca-runtime-test-mocks.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + makeWorktreeInfo, + store +} from './orca-runtime-test-fixtures.spec' +import type { Repo } from '../../shared/repo-types' +import type { RuntimeClientEvent } from '../../shared/runtime-client-events' + +const externalPath = '/tmp/elsewhere/external' + +async function createScannedRuntime() { + let repo: Repo = { ...store.getRepos()[0], externalWorktreeVisibility: 'hide' } + const runtime = new OrcaRuntimeService({ + ...store, + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getRepos: () => [repo], + updateRepo: (_target: Repo, updates: Partial) => { + repo = { ...repo, ...updates } + return repo + } + }) + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(TEST_REPO_PATH), + makeWorktreeInfo(externalPath) + ]) + const events: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => events.push(event)) + const isExternalVisible = async () => + (await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)).worktrees.find( + (worktree) => worktree.path === externalPath + )?.visible + expect(await isExternalVisible()).toBe(false) + return { runtime, events, isExternalVisible } +} + +describe('repo.update worktree refresh', () => { + it('re-lists a newly shown external worktree without rescanning Git', async () => { + const { runtime, events, isExternalVisible } = await createScannedRuntime() + + await runtime.updateRepo(`id:${TEST_REPO_ID}`, { externalWorktreeVisibility: 'show' }) + + expect(events).toContainEqual({ type: 'worktreesChanged', repoId: TEST_REPO_ID }) + expect(await isExternalVisible()).toBe(true) + expect(listWorktrees).toHaveBeenCalledTimes(1) + }) + + it('skips the worktree refresh for catalog-only edits', async () => { + const { runtime, events } = await createScannedRuntime() + + await runtime.updateRepo(`id:${TEST_REPO_ID}`, { displayName: 'renamed' }) + + expect(events.some((event) => event.type === 'worktreesChanged')).toBe(false) + }) +}) diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index ea3379cdd66..0bccd322c72 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -23,6 +23,7 @@ type RuntimeRepositorySettingsDependencies = { invalidateResolvedWorktrees: () => void invalidateWorktreeScan: (repoId: string) => void notifyReposChanged: () => void + notifyWorktreesChanged: (repoId: string, options: { invalidateGitScans: false }) => void } type RepositoryUpdates = Partial< @@ -44,6 +45,8 @@ type RepositoryUpdates = Partial< | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' > @@ -54,6 +57,16 @@ type RepositoryUpdates = Partial< ghAccount?: GhAccountBinding | null } +// Main computes each row's `visible` from these over cached raw Git scans, so re-list without rescanning. +const WORKTREE_VISIBILITY_INPUTS = [ + 'externalWorktreeVisibility', + 'importedExternalWorktreePaths', + 'agentWorktreeVisibility', + 'worktreeBasePath', + 'customWorktreeVisibilitySources', + 'worktreeVisibilitySourcePreferences' +] as const satisfies readonly (keyof RepositoryUpdates)[] + function omitUndefined>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -120,6 +133,9 @@ export class RuntimeRepositorySettingsController { this.deps.invalidateWorktreeScan(repo.id) } this.deps.notifyReposChanged() + if (WORKTREE_VISIBILITY_INPUTS.some((key) => key in updates)) { + this.deps.notifyWorktreesChanged(repo.id, { invalidateGitScans: false }) + } return updated }