diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts new file mode 100644 index 00000000000..e3ccee07086 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { createLineageResolutionContext } from './lineage-owner-resolution' +import { getFolderLineageCandidateRepos } from './workspace-lineage-filtering' + +vi.mock('../../worktree-logic', () => ({ parseWorktreeId: vi.fn() })) + +function repo(id: string, fields: Partial = {}): Repo { + return { id, path: `/root/${id}`, displayName: id, badgeColor: '#000', addedAt: 0, ...fields } +} + +function group(id = 'group', fields: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...fields + } +} + +function folder(fields: Partial = {}): FolderWorkspace { + return { + id: 'folder', + projectGroupId: 'group', + name: 'folder', + folderPath: '/root', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0, + ...fields + } +} + +function context(repos: Repo[], groups = [group()]) { + const store = { + getRepos: () => repos, + getFolderWorkspaces: () => [], + getProjectGroups: () => groups + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Context construction only reads these three store catalogs. + return createLineageResolutionContext(store as never) +} + +describe('folder lineage candidate lookup', () => { + it('reads grouped target membership once across a large path-candidate list', () => { + let targetReads = 0 + const grouped = Array.from({ length: 300 }, (_, index): Repo => ({ + ...repo(`grouped-${index}`, { projectGroupId: 'group', path: `/elsewhere/${index}` }), + get connectionId() { + targetReads += 1 + return null + } + })) + const candidates = Array.from({ length: 1_000 }, (_, index) => repo(`path-${index}`)) + const result = getFolderLineageCandidateRepos(context([...grouped, ...candidates]), folder()) + + expect(result).toEqual([...grouped, ...candidates]) + expect(targetReads).toBe(300) + }) + + it('preserves descendant-group priority, path boundaries and local/SSH membership', () => { + const entries = [ + repo('path-a', { connectionId: 'a' }), + repo('group-b', { projectGroupId: 'nested', path: '/elsewhere', connectionId: 'b' }), + repo('local-null', { connectionId: null }), + repo('group-local', { projectGroupId: 'group' }), + repo('path-b', { connectionId: 'b' }), + repo('local-absent'), + repo('sibling', { path: '/root-other/repo', connectionId: 'b' }), + repo('root', { path: '/root', connectionId: 'b' }) + ] + const snapshot = structuredClone(entries) + const result = getFolderLineageCandidateRepos( + context(entries, [group(), group('nested', { parentGroupId: 'group' })]), + folder() + ) + + expect(result.map((entry) => entry.id)).toEqual([ + 'group-b', + 'group-local', + 'local-null', + 'path-b', + 'local-absent', + 'root' + ]) + expect(result[0]).toBe(entries[1]) + expect(entries).toEqual(snapshot) + }) + + it.each([ + { folderTarget: 'a', groupTarget: 'b', expected: ['grouped', 'a'] }, + { folderTarget: null, groupTarget: 'b', expected: ['grouped', 'b'] }, + { folderTarget: undefined, groupTarget: undefined, expected: ['grouped', 'local'] } + ])( + 'keeps explicit target precedence: $folderTarget / $groupTarget', + ({ folderTarget, groupTarget, expected }) => { + const entries = [ + repo('a', { connectionId: 'a' }), + repo('b', { connectionId: 'b' }), + repo('local'), + repo('grouped', { projectGroupId: 'group', path: '/outside' }) + ] + expect( + getFolderLineageCandidateRepos( + context(entries, [group('group', { connectionId: groupTarget })]), + folder({ connectionId: folderTarget }) + ).map((entry) => entry.id) + ).toEqual(expected) + } + ) + + it('keeps every contained target when the folder has no grouped repositories', () => { + const entries = [ + repo('local'), + repo('remote', { connectionId: 'a' }), + repo('outside', { path: '/elsewhere' }) + ] + expect(getFolderLineageCandidateRepos(context(entries), folder())).toEqual(entries.slice(0, 2)) + }) + + it('does not read grouped targets when there are no path candidates', () => { + let targetReads = 0 + const grouped: Repo = { + ...repo('grouped', { projectGroupId: 'group' }), + get connectionId() { + targetReads += 1 + return 'a' + } + } + expect(getFolderLineageCandidateRepos(context([grouped]), folder())).toEqual([grouped]) + expect(targetReads).toBe(0) + }) + + it('rebuilds target membership after a later change in the same context', () => { + const grouped = repo('grouped', { projectGroupId: 'group', connectionId: 'a' }) + const a = repo('a', { connectionId: 'a' }) + const b = repo('b', { connectionId: 'b' }) + const current = context([grouped, a, b]) + expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, a]) + grouped.connectionId = 'b' + expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, b]) + }) +}) diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts index 3106812418c..c478b59b81a 100644 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts @@ -36,18 +36,20 @@ export function getFolderLineageCandidateRepos( ) const group = context.groupsById.get(folder.projectGroupId)?.[0] const connectionId = folder.connectionId ?? group?.connectionId ?? null - return connectionId - ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - : grouped.length > 0 - ? [ - ...grouped, - ...pathRepos.filter((repo) => - new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( - repo.connectionId ?? null - ) - ) - ] - : pathRepos + if (connectionId) { + return [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + } + if (grouped.length === 0) { + return pathRepos + } + if (pathRepos.length === 0) { + return grouped + } + const groupedConnectionIds = new Set(grouped.map((repo) => repo.connectionId ?? null)) + return [ + ...grouped, + ...pathRepos.filter((repo) => groupedConnectionIds.has(repo.connectionId ?? null)) + ] } export function resolveFolderLineageOwner(