Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/renderer/src/components/cmd-j/palette-project-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../../../../shared/project-types
import type { Repo } from '../../../../shared/repo-types'
import { translate } from '@/i18n/i18n'
import { getProjectGroupHeaderKey } from '../../../../shared/sidebar-list/group-keys'
import { getDistinctProjectGroups } from '../../../../shared/project-groups'
import { getProjectHeaderRevealTarget } from '../../../../shared/project-section-grouping'
import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping'

Expand Down Expand Up @@ -81,7 +82,7 @@ function buildCmdJProjectSearchCandidates({
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
const candidates: CmdJProjectSearchResult[] = []

projectGroups.forEach((group, order) => {
getDistinctProjectGroups(projectGroups).forEach((group, order) => {
candidates.push({
id: `project-group:${group.id}`,
kind: 'project-group',
Expand Down
17 changes: 17 additions & 0 deletions src/renderer/src/components/cmd-j/palette-results.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,23 @@ describe('Cmd+J project and repo-group search', () => {
})
})

it('lists a same-id Project Group from two hosts once, by the name the sidebar shows', () => {
const search = (query: string) =>
searchCmdJProjectResults({
query,
projectGroups: [
{ ...projectGroup('group-1', 'Infrastructure'), executionHostId: 'local' },
{ ...projectGroup('group-1', 'Platform'), executionHostId: 'runtime:env-b' }
],
repos: [],
projects: [],
projectHostSetups: []
}).map((result) => [result.id, result.title])

expect(search('platform')).toEqual([['project-group:group-1', 'Platform']])
expect(search('infra')).toEqual([])
})

it('finds a Project by project name and a repo-backed fallback by repo name', () => {
const projectResults = searchCmdJProjectResults({
query: 'api',
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/components/sidebar/WorktreeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ const WorktreeList = React.memo(function WorktreeList({
workspaceStatuses={workspaceStatuses}
projectGrouping={projectGrouping}
projectGroups={projectGroups}
visibleProjectGroups={visibleScope.visibleProjectGroupsForRows}
onMoveWorktreeToStatus={statusMutations.moveWorktreeToStatus}
onMoveWorktreesToStatus={statusMutations.moveWorktreesToStatus}
onMoveWorktreesToStatusAtIndex={statusMutations.moveWorktreesToStatusAtIndex}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* #30045: moving a project to a managed server copies its project group to the server under the
* same id, and the desktop keeps its own copy. The sidebar must still draw that group once.
*/
import { describe, expect, it } from 'vitest'
import { buildRows } from './worktree-list/grouping/build-rows.test-fixture'
import { repo, worktree } from './worktree-list-groups-test-fixtures'
import { getRenderRowKey } from './worktree-list/listing/render-row'
import { getSidebarRowRevealAncestorKeys } from './worktree-list/navigation/reveal-ancestors'
import { getProjectGroupOccurrencePaths } from '../../../../shared/sidebar-list/project-group-sections'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'

const REMOTE_HOST: ExecutionHostId = 'runtime:env-b'

function group(executionHostId: ExecutionHostId, overrides: Partial<ProjectGroup> = {}) {
return {
id: 'group-shared',
name: 'Platform',
parentPath: null,
executionHostId,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
} satisfies ProjectGroup
}

const groupedRepo: Repo = { ...repo, projectGroupId: 'group-shared' }
const localWorktree: Worktree = {
...worktree,
id: 'wt-local',
hostId: 'local'
}
const remoteWorktree: Worktree = {
...worktree,
id: 'wt-remote',
hostId: REMOTE_HOST
}

function rowsFor(projectGroups: readonly ProjectGroup[], collapsed = new Set<string>()) {
const worktrees = [localWorktree, remoteWorktree]
return buildRows(
'repo',
worktrees,
new Map([[groupedRepo.id, groupedRepo]]),
null,
collapsed,
undefined,
undefined,
undefined,
{},
new Map(worktrees.map((entry) => [entry.id, entry])),
false,
undefined,
projectGroups
)
}

describe('a project group both hosts publish under one id', () => {
it('draws one header holding the project, not a second empty one', () => {
const rows = rowsFor([group('local'), group(REMOTE_HOST, { name: 'Renamed on server' })])

const groupHeaders = rows.filter((row) => row.type === 'header' && row.projectGroup)
expect(groupHeaders).toHaveLength(1)
// The same copy every id-keyed lookup (and Cmd+J) resolves to.
expect(groupHeaders[0]).toMatchObject({
key: 'project-group:group-shared',
label: 'Renamed on server',
count: 1
})
expect(rows.filter((row) => row.type === 'item')).toHaveLength(2)
})

it('gives every rendered row a distinct key', () => {
const keys = rowsFor([group('local'), group(REMOTE_HOST)]).map(getRenderRowKey)

expect(new Set(keys).size).toBe(keys.length)
})

it('keeps a same-id child group nested once under its parent', () => {
const parent = (hostId: ExecutionHostId) => group(hostId, { id: 'parent', name: 'Parent' })
const child = (hostId: ExecutionHostId) => group(hostId, { parentGroupId: 'parent' })

const rows = rowsFor([parent('local'), child('local'), parent(REMOTE_HOST), child(REMOTE_HOST)])

expect(
rows.flatMap((row) =>
row.type === 'header' && row.projectGroup ? [[row.key, row.projectGroupDepth]] : []
)
).toEqual([
['project-group:parent', 0],
['project-group:group-shared', 1]
])
})

it('reveals the drawn copy when the copies have different parents', () => {
const groups = [
group('local'),
group(REMOTE_HOST, { id: 'parent', name: 'Parent' }),
group(REMOTE_HOST, { parentGroupId: 'parent' })
]

const collapsedParentRows = rowsFor(groups, new Set(['project-group:parent']))
expect(
collapsedParentRows.filter((row) => row.type === 'header').map((row) => row.key)
).toEqual(['project-group:parent'])
expect(
getSidebarRowRevealAncestorKeys({
rowKey: 'project-group:group-shared',
repoMap: new Map(),
projectGroups: groups
})
).toEqual(['project-group:parent'])
expect(getProjectGroupOccurrencePaths(groups).get('group-shared')?.parent?.key).toBe(
'project-group:parent'
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe('worktree list folder reveal', () => {
getFolderWorkspaceRevealGroupKeys(
folderWorkspaceKey(folderWorkspace.id),
[{ ...folderWorkspace, executionHostId: otherHostId }, folderWorkspace],
[root, child, otherRoot, otherChild],
[otherRoot, otherChild, root, child],
{ executionHostId, defaultHostId: 'local' }
)
).toEqual([
Expand All @@ -151,6 +151,31 @@ describe('worktree list folder reveal', () => {
])
}
)

it('follows the parents the sidebar draws when same-id group copies disagree (#30045)', () => {
// The sidebar draws one header per group id, from the last copy, so reveal must open its parent.
const localChild = makeProjectGroup({ id: 'group-child', executionHostId: 'local' })
const remoteRoot = makeProjectGroup({ id: 'remote-root', executionHostId: 'runtime:other' })
const remoteChild = makeProjectGroup({
id: localChild.id,
parentGroupId: remoteRoot.id,
executionHostId: 'runtime:other'
})
const folderWorkspace = makeFolderWorkspace({ executionHostId: 'local' })

expect(
getFolderWorkspaceRevealGroupKeys(
folderWorkspaceKey(folderWorkspace.id),
[folderWorkspace],
[localChild, remoteRoot, remoteChild],
{ executionHostId: 'local', defaultHostId: 'local' }
)
).toEqual([
getProjectGroupHeaderKey(remoteRoot.id),
getProjectGroupHeaderKey(localChild.id),
'host:local'
])
})
})

describe('reveal keys under non-repo grouping', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,17 @@ export function getFolderWorkspaceRevealGroupKeys(
)
.map((group) => [group.id, group])
)
// Why unfiltered: same-id host copies render under the last copy's parents, whichever host's row
// the folder is (getDistinctProjectGroups); header keys carry no host.
const renderedGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
const keys: string[] = []
const seen = new Set<string>()
let groupId: string | null = folderWorkspace.projectGroupId
let groupId: string | null = groupsById.has(folderWorkspace.projectGroupId)
? folderWorkspace.projectGroupId
: null
while (groupId && !seen.has(groupId)) {
seen.add(groupId)
const group = groupsById.get(groupId)
const group = renderedGroupsById.get(groupId)
if (!group) {
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export function getSidebarRowRevealAncestorKeys(args: {
}): string[] {
if (args.rowKey.startsWith('project-group:')) {
const groupId = args.rowKey.slice('project-group:'.length)
const group = args.projectGroups.find((candidate) => candidate.id === groupId)
// Why the last copy: same-id host copies render as that one (getDistinctProjectGroups).
const group = args.projectGroups.findLast((candidate) => candidate.id === groupId)
return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups)
}
const keys = new Set<string>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
prCache: props.prCache,
workspaceStatuses,
settings,
projectGroups,
projectGroups: props.visibleProjectGroups ?? projectGroups,
projectGrouping: props.projectGrouping,
flashRevealedRow: reveal.flashRevealedRow,
markRevealScroll: scrollSuppression.markRevealScroll,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export type VirtualizedWorktreeViewportProps = {
workspaceStatuses: readonly WorkspaceStatusDefinition[]
projectGrouping?: ProjectGroupingModel
projectGroups?: readonly ProjectGroup[]
/** The host-filtered groups the rows were built from; reveal must walk the same parents. */
visibleProjectGroups?: readonly ProjectGroup[]
onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void
onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void
Expand Down
11 changes: 11 additions & 0 deletions src/shared/project-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,14 @@ export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string
}
return max + 1
}

/**
* One row per group id, in first-seen order. Hosts can publish one group under the same id (a
* project moved to a managed server keeps the desktop's copy, #30045); the last copy wins, as in
* every id-keyed group lookup, so the sidebar and search agree on its name.
*/
export function getDistinctProjectGroups<T extends Pick<ProjectGroup, 'id'>>(
projectGroups: readonly T[]
): T[] {
return [...new Map(projectGroups.map((group) => [group.id, group])).values()]
}
7 changes: 4 additions & 3 deletions src/shared/sidebar-list/project-group-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from './folder-workspace-lanes'
import type { ProjectGroup } from '../project-group-types'
import type { ProjectOrderBy } from '../ui-chrome-types'
import { getEffectiveProjectGroupManualRank } from '../project-groups'
import { getDistinctProjectGroups, getEffectiveProjectGroupManualRank } from '../project-groups'
import { getProjectGroupHeaderKey } from './group-keys'
import { appendOrderedGroups } from './group-sections'
import { compareCodePoints, compareSidebarText } from './text-order'
Expand Down Expand Up @@ -76,7 +76,8 @@ export function appendProjectGroupSections(
)
}
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
for (const group of projectGroups) {
// Why distinct: membership is id-keyed, so a same-id copy's header would sit empty (#30045).
for (const group of getDistinctProjectGroups(projectGroups)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep conflicting host trees from hiding grouped projects.

If same-ID copies from two hosts interleave, selecting the last copy of each ID can create a parent cycle. For example, local A is a root and local B is its child, while remote B is a root and remote A is its child. In the order [local A, remote B, remote A, local B], this loop selects A → B → A. Neither group reaches the root traversal at Line 132. The fallback at Lines 137-139 then skips their grouped repos because both IDs exist. Resolve conflicting parents or detect cycles before building the tree so those projects remain visible.

const parentId =
group.parentGroupId && projectGroupsById.has(group.parentGroupId) ? group.parentGroupId : null
const children = childGroupsByParentId.get(parentId) ?? []
Expand Down Expand Up @@ -152,7 +153,7 @@ export function appendProjectGroupSections(
export function getProjectGroupOccurrencePaths(projectGroups: readonly ProjectGroup[]) {
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const paths = new Map<string, OccurrenceSectionPath>()
for (const group of projectGroups) {
for (const group of getDistinctProjectGroups(projectGroups)) {
const pending: ProjectGroup[] = []
const seen = new Set<string>()
let current: ProjectGroup | undefined = group
Expand Down
Loading