From 86ef00ad55cb229763cfefdd7b46884e01a71888 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:28:29 -0700 Subject: [PATCH] fix(projects): a project move names the row it moves; a paired server's twin by its SSH target (#30794) * fix(projects): a project move names the row it moves; a paired server's twin by its SSH target * fix(sidebar): pass the header's row without an assertion; e2e helpers and focus baseline follow the row-shaped move * fix(sidebar): a header drop re-reads the dragged row and drops nothing if its group changed mid-drag * fix(sidebar): read live repos through a stable store reader, not a ref written during render --- config/focus-setting-read-baseline.txt | 2 +- .../construction/runtime-host-services.ts | 2 +- src/main/runtime/rpc/methods/repo.test.ts | 5 +- src/main/runtime/rpc/methods/repo.ts | 7 ++- .../runtime-project-group-controller.ts | 12 +++-- .../runtime-remove-project-host-scope.spec.ts | 16 +++++++ .../project-header-drag-commit.test.ts | 48 +++++++++++++++---- .../sidebar/project-header-drag-commit.ts | 30 +++++------- .../sidebar/project-header-drag-contract.ts | 14 ++---- .../sidebar/project-header-drag-start.test.ts | 34 +++++-------- .../sidebar/project-header-drag-start.ts | 11 ++--- .../components/sidebar/project-header-drag.ts | 41 ++++++++-------- .../use-worktree-context-menu-commands.ts | 14 ++---- .../worktree-list/drag/use-header-drag.ts | 7 ++- .../worktree-list/rows/SectionHeader.tsx | 7 +-- .../rows/use-project-group-dialogs.ts | 9 ++-- .../project-groups/project-group-mutations.ts | 21 ++++---- src/renderer/src/store/repos/repo-state.ts | 7 +-- .../src/store/slices/repo-host-identity.ts | 9 ++++ ...epos-project-group-cross-host-move.test.ts | 43 +++++++++++++---- .../store/slices/repos-project-groups.test.ts | 6 +-- src/renderer/src/store/slices/repos.test.ts | 8 +++- src/shared/rpc-contract/repo-params.ts | 4 +- .../project-group-creation-visibility.spec.ts | 6 ++- tests/e2e/sidebar-measured-row-scroll.spec.ts | 2 +- .../worktree-lineage-toggle-dialog.spec.ts | 6 ++- 26 files changed, 225 insertions(+), 146 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 8b10bcf2eec..883e6c9181a 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -391,7 +391,7 @@ 3 src/renderer/src/store/github/work-item-fetch-actions.ts 2 src/renderer/src/store/github/work-item-routing.ts 9 src/renderer/src/store/index.ts -3 src/renderer/src/store/project-groups/project-group-mutations.ts +2 src/renderer/src/store/project-groups/project-group-mutations.ts 1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts 1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts 2 src/renderer/src/store/repos/repo-add-actions.ts diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index ccb7fd0fc55..d08e7988fea 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -296,7 +296,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW protected readonly projectGroups = new RuntimeProjectGroupController({ getStore: () => this.store, - resolveRepo: (selector) => this.resolveRepoSelector(selector), + resolveRepo: (...args) => this.managedWorktreeQueries.resolveRepoForConnection(...args), notifyReposChanged: () => this.notifyReposChanged(), resolveFolderConnectionId: (workspace) => this.resolveFolderWorkspaceConnectionId(workspace), teardownFolderWorkspacePtys: (worktreeId, connectionId) => diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 77e504faff4..ea4edaa4a61 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -714,7 +714,8 @@ describe('repo RPC methods', () => { makeRequest('projectGroup.moveProject', { repo: 'repo-1', groupId: group.id, - order: 2 + order: 2, + connectionId: 'srv-ssh' }) ) const folderListResponse = await dispatcher.dispatch(makeRequest('folderWorkspace.list')) @@ -757,7 +758,7 @@ describe('repo RPC methods', () => { isCollapsed: true }) expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id) - expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2) + expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2, 'srv-ssh') expect(runtime.listVisibleFolderWorkspaces).toHaveBeenCalled() expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({ projectGroupId: group.id, diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 3f7ac96cc83..67c942ef6da 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -78,7 +78,12 @@ export const REPO_METHODS = [ params: ProjectGroupMoveProject, handler: async (params, context) => ({ repo: projectRepoVisibilityForClient( - await context.runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order), + await context.runtime.moveProjectToGroup( + params.repo, + params.groupId ?? null, + params.order, + params.connectionId + ), context ) }) diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 9480bec0e8f..8acd09e2052 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -28,7 +28,8 @@ import { type RuntimeProjectGroupDependencies = { getStore: () => RuntimeStore | null - resolveRepo: (selector: string) => Promise + // `connectionId` narrows twins by their SSH target; undefined keeps the plain selector. + resolveRepo: (selector: string, connectionId?: string | null) => Promise notifyReposChanged: () => void resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise @@ -128,12 +129,17 @@ export class RuntimeProjectGroupController { return { deleted } } - async moveProject(repoSelector: string, groupId: string | null, order?: number): Promise { + async moveProject( + repoSelector: string, + groupId: string | null, + order?: number, + connectionId?: string | null + ): Promise { const store = this.deps.getStore() if (!store?.moveProjectToGroup) { throw new Error('runtime_unavailable') } - const repo = await this.deps.resolveRepo(repoSelector) + const repo = await this.deps.resolveRepo(repoSelector, connectionId) assertWslMoveRepoWritable(store, repo) const moved = store.moveProjectToGroup(repo, groupId, order) if (!moved) { diff --git a/src/main/runtime/runtime-remove-project-host-scope.spec.ts b/src/main/runtime/runtime-remove-project-host-scope.spec.ts index 1f853da6510..dbf002bd7a2 100644 --- a/src/main/runtime/runtime-remove-project-host-scope.spec.ts +++ b/src/main/runtime/runtime-remove-project-host-scope.spec.ts @@ -123,6 +123,22 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void { ) expect(repos.map((repo) => repo.projectGroupId)).toEqual([undefined, undefined]) }) + + it('moves the SSH row a paired client names by its connection', async () => { + const { runtime, repos } = createRuntime() + + await runtime.moveProjectToGroup('dup', 'group-1', undefined, 'ssh-1') + + expect(repos.map((repo) => repo.projectGroupId)).toEqual([undefined, 'group-1']) + }) + + it('moves the local row a paired client names with no connection', async () => { + const { runtime, repos } = createRuntime() + + await runtime.moveProjectToGroup('dup', 'group-1', undefined, null) + + expect(repos.map((repo) => repo.projectGroupId)).toEqual(['group-1', undefined]) + }) }) describe('repo.update with the same repo id on two execution hosts', () => { diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index aed02a9876f..28a3882909e 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { commitProjectHeaderDragDrop } from './project-header-drag-commit' import type { ProjectHeaderDragSession } from './project-header-drag-contract' +import { getProjectHeaderDragBucketKey } from './project-header-drop' import type { Repo } from '../../../../shared/repo-types' function makeRepo(id: string, overrides: Partial = {}): Repo { @@ -17,12 +18,12 @@ function makeRepo(id: string, overrides: Partial = {}): Repo { } function makeSession( - repoId: string, + repo: Repo, sidebarRepoHeaderIds: readonly string[] ): ProjectHeaderDragSession { return { - repoId, - bucketKey: 'ungrouped', + repo, + bucketKey: getProjectHeaderDragBucketKey(repo), sidebarRepoHeaderIds, pointerId: 1, headerRects: [], @@ -41,10 +42,11 @@ describe('commitProjectHeaderDragDrop', () => { const repoById = new Map(repos.map((repo) => [repo.id, repo])) commitProjectHeaderDragDrop({ - session: makeSession('c', ['a', 'b', 'c']), + session: makeSession(makeRepo('c'), ['a', 'b', 'c']), sidebarDropIndex: 0, orderedRepoIds: ['a', 'b', 'c'], repoById, + liveRepos: repos, usesProjectGroupOrdering: false, onCommitRepoOrder, onCommitProjectGroupOrder: vi.fn() @@ -59,10 +61,11 @@ describe('commitProjectHeaderDragDrop', () => { const repoById = new Map(repos.map((repo) => [repo.id, repo])) commitProjectHeaderDragDrop({ - session: makeSession('same', ['b', 'same', 'c']), + session: makeSession(makeRepo('same'), ['b', 'same', 'c']), sidebarDropIndex: 0, orderedRepoIds: ['b', 'same', 'c', 'same'], repoById, + liveRepos: repos, usesProjectGroupOrdering: false, onCommitRepoOrder, onCommitProjectGroupOrder: vi.fn() @@ -77,10 +80,11 @@ describe('commitProjectHeaderDragDrop', () => { const repoById = new Map(repos.map((repo) => [repo.id, repo])) commitProjectHeaderDragDrop({ - session: makeSession('same', ['b', 'same', 'c']), + session: makeSession(makeRepo('same'), ['b', 'same', 'c']), sidebarDropIndex: 2, orderedRepoIds: ['b', 'same', 'c', 'same'], repoById, + liveRepos: repos, usesProjectGroupOrdering: false, onCommitRepoOrder, onCommitProjectGroupOrder: vi.fn() @@ -89,25 +93,49 @@ describe('commitProjectHeaderDragDrop', () => { expect(onCommitRepoOrder).not.toHaveBeenCalled() }) - it('commits projectGroupOrder when project groups are present', () => { + it('commits projectGroupOrder for the dragged row, not the id-keyed twin on another host', () => { const onCommitProjectGroupOrder = vi.fn() const repos = [ makeRepo('a', { projectGroupId: 'group-1' }), makeRepo('b', { projectGroupId: 'group-1' }), - makeRepo('c', { projectGroupId: 'group-1', connectionId: 'ssh-1' }) + makeRepo('c', { projectGroupId: 'group-1', executionHostId: 'local' }) ] const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const sshTwin = makeRepo('c', { projectGroupId: 'group-1', connectionId: 'ssh-1' }) commitProjectHeaderDragDrop({ - session: makeSession('c', ['a', 'b', 'c']), + session: makeSession(sshTwin, ['a', 'b', 'c']), sidebarDropIndex: 0, orderedRepoIds: ['a', 'b', 'c'], repoById, + liveRepos: [...repos, sshTwin], usesProjectGroupOrdering: true, onCommitRepoOrder: vi.fn(), onCommitProjectGroupOrder }) - expect(onCommitProjectGroupOrder).toHaveBeenCalledWith('c', 'group-1', -1, 'ssh:ssh-1') + expect(onCommitProjectGroupOrder).toHaveBeenCalledWith(sshTwin, 'group-1', -1) + }) + + it('drops nothing when the dragged row changed group during the drag', () => { + const onCommitProjectGroupOrder = vi.fn() + const repos = [ + makeRepo('a', { projectGroupId: 'group-1' }), + makeRepo('b', { projectGroupId: 'group-1' }) + ] + const dragged = makeRepo('c', { projectGroupId: 'group-1' }) + + commitProjectHeaderDragDrop({ + session: makeSession(dragged, ['a', 'b', 'c']), + sidebarDropIndex: 0, + orderedRepoIds: ['a', 'b', 'c'], + repoById: new Map([...repos, dragged].map((repo) => [repo.id, repo])), + liveRepos: [...repos, { ...dragged, projectGroupId: 'group-2' }], + usesProjectGroupOrdering: true, + onCommitRepoOrder: vi.fn(), + onCommitProjectGroupOrder + }) + + expect(onCommitProjectGroupOrder).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 2bca5c764f8..1b767865602 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -1,35 +1,33 @@ import { getLogicalRepoOrderRankById } from '../../../../shared/project-section-order' import { applyAllRepoInsertAt, + getProjectHeaderDragBucketKey, getProjectGroupOrderForSidebarDrop, mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, mapSidebarRepoDropIndexToAllRepoInsertAt } from './project-header-drop' import type { ProjectHeaderDragSession } from './project-header-drag-contract' import type { Repo } from '../../../../shared/repo-types' -import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { isSameRepoRow } from '@/store/slices/repo-host-identity' export function commitProjectHeaderDragDrop(args: { session: ProjectHeaderDragSession sidebarDropIndex: number orderedRepoIds: readonly string[] repoById: ReadonlyMap + liveRepos: readonly Repo[] usesProjectGroupOrdering: boolean onCommitRepoOrder: (orderedIds: string[]) => void - onCommitProjectGroupOrder: ( - repoId: string, - projectGroupId: string | null, - order: number, - hostId: ExecutionHostId - ) => void + onCommitProjectGroupOrder: (project: Repo, projectGroupId: string | null, order: number) => void }): void { - const draggedRepo = args.repoById.get(args.session.repoId) - if (!draggedRepo) { + const draggedRepo = args.liveRepos.find((repo) => isSameRepoRow(repo, args.session.repo)) + // Why: a group change mid-drag (another window or client) moved the row out of this bucket. + if (!draggedRepo || getProjectHeaderDragBucketKey(draggedRepo) !== args.session.bucketKey) { return } const sidebarRepoHeaderIds = args.session.sidebarRepoHeaderIds - const sourceIndex = sidebarRepoHeaderIds.indexOf(args.session.repoId) + const sourceIndex = sidebarRepoHeaderIds.indexOf(draggedRepo.id) // Why: both slots bordering the dragged header are visual no-ops. In // particular, do not compact paired-host occurrences on an unchanged drop. if ( @@ -42,7 +40,7 @@ export function commitProjectHeaderDragDrop(args: { if (args.usesProjectGroupOrdering) { const siblings = sidebarRepoHeaderIds - .filter((repoId) => repoId !== args.session.repoId) + .filter((repoId) => repoId !== draggedRepo.id) .map((repoId) => args.repoById.get(repoId)) .filter((repo): repo is Repo => repo !== undefined) const siblingDropIndex = mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ @@ -65,13 +63,7 @@ export function commitProjectHeaderDragDrop(args: { dropIndex: siblingDropIndex, repoOrderRankById }) - // Why the dragged row's host: its group and order came from that row, so main writes that twin. - args.onCommitProjectGroupOrder( - args.session.repoId, - draggedRepo.projectGroupId ?? null, - order, - getRepoExecutionHostId(draggedRepo) - ) + args.onCommitProjectGroupOrder(draggedRepo, draggedRepo.projectGroupId ?? null, order) return } @@ -80,7 +72,7 @@ export function commitProjectHeaderDragDrop(args: { sidebarRepoHeaderIds, args.orderedRepoIds ) - const next = applyAllRepoInsertAt(args.orderedRepoIds, args.session.repoId, insertAt) + const next = applyAllRepoInsertAt(args.orderedRepoIds, draggedRepo.id, insertAt) if (!next) { return } diff --git a/src/renderer/src/components/sidebar/project-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-header-drag-contract.ts index 476f13fea12..92fba822a61 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-contract.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-contract.ts @@ -2,7 +2,6 @@ import type { PointerEvent } from 'react' import type { ProjectHeaderDragBucketKey, ProjectHeaderDragRect } from './project-header-drop' import type { Repo } from '../../../../shared/repo-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' export type RepoDragState = { draggingRepoId: string | null @@ -20,24 +19,21 @@ export type UseRepoHeaderDragArgs = { orderedRepoIds: string[] sidebarRepoHeaderIdsByBucket: ReadonlyMap repoById: ReadonlyMap + getLiveRepos: () => readonly Repo[] usesProjectGroupOrdering: boolean onCommitRepoOrder: (orderedIds: string[]) => void - onCommitProjectGroupOrder: ( - repoId: string, - projectGroupId: string | null, - order: number, - hostId: ExecutionHostId - ) => void + onCommitProjectGroupOrder: (project: Repo, projectGroupId: string | null, order: number) => void getScrollContainer: () => HTMLElement | null } export type RepoHeaderDragController = { state: RepoDragState - onHandlePointerDown: (event: PointerEvent, repoId: string) => void + onHandlePointerDown: (event: PointerEvent, repo: Repo) => void } export type ProjectHeaderDragSession = { - repoId: string + // Why the row, not its id: a twin on another host shares the id. + repo: Repo bucketKey: ProjectHeaderDragBucketKey sidebarRepoHeaderIds: readonly string[] pointerId: number diff --git a/src/renderer/src/components/sidebar/project-header-drag-start.test.ts b/src/renderer/src/components/sidebar/project-header-drag-start.test.ts index da1a07c26cf..310e24ee9c3 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-start.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-start.test.ts @@ -24,7 +24,6 @@ describe('createProjectHeaderDragSession', () => { const scrollContainer = document.createElement('div') document.body.append(scrollContainer, handleEl) - const repoById = new Map([['repo-a', createRepo('repo-a')]]) const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) const session = createProjectHeaderDragSession({ @@ -36,8 +35,7 @@ describe('createProjectHeaderDragSession', () => { target: handleEl, currentTarget: handleEl } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) @@ -54,7 +52,6 @@ describe('createProjectHeaderDragSession', () => { const scrollContainer = document.createElement('div') document.body.append(scrollContainer, header) - const repoById = new Map([['repo-a', createRepo('repo-a')]]) const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) const session = createProjectHeaderDragSession({ @@ -66,13 +63,12 @@ describe('createProjectHeaderDragSession', () => { target: label, currentTarget: header } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) - expect(session?.repoId).toBe('repo-a') + expect(session?.repo.id).toBe('repo-a') }) it('arms a drag session when pressing the project icon svg (SVGElement target)', () => { @@ -85,7 +81,6 @@ describe('createProjectHeaderDragSession', () => { const scrollContainer = document.createElement('div') document.body.append(scrollContainer, header) - const repoById = new Map([['repo-a', createRepo('repo-a')]]) const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) const session = createProjectHeaderDragSession({ @@ -97,13 +92,12 @@ describe('createProjectHeaderDragSession', () => { target: iconSvg, currentTarget: header } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) - expect(session?.repoId).toBe('repo-a') + expect(session?.repo.id).toBe('repo-a') }) it('does not arm a drag session when pressing an svg icon inside an action button', () => { @@ -119,7 +113,6 @@ describe('createProjectHeaderDragSession', () => { const scrollContainer = document.createElement('div') document.body.append(scrollContainer, header) - const repoById = new Map([['repo-a', createRepo('repo-a')]]) const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) const session = createProjectHeaderDragSession({ @@ -131,8 +124,7 @@ describe('createProjectHeaderDragSession', () => { target: actionIcon, currentTarget: header } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) @@ -154,7 +146,6 @@ describe('createProjectHeaderDragSession', () => { const scrollContainer = document.createElement('div') document.body.append(scrollContainer, header) - const repoById = new Map([['repo-a', createRepo('repo-a')]]) const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]]) const sessionFromActions = createProjectHeaderDragSession({ @@ -166,8 +157,7 @@ describe('createProjectHeaderDragSession', () => { target: actions, currentTarget: header } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) @@ -180,8 +170,7 @@ describe('createProjectHeaderDragSession', () => { target: label, currentTarget: header } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) @@ -194,15 +183,14 @@ describe('createProjectHeaderDragSession', () => { target: header, currentTarget: header } as unknown as React.PointerEvent, - repoId: 'repo-a', - repoById, + repo: createRepo('repo-a'), sidebarRepoHeaderIdsByBucket, getScrollContainer: () => scrollContainer }) expect(sessionFromActions).toBeNull() - expect(sessionFromLabel?.repoId).toBe('repo-a') + expect(sessionFromLabel?.repo.id).toBe('repo-a') // Why: target === currentTarget is treated as the empty row (not an action). - expect(sessionFromRowPadding?.repoId).toBe('repo-a') + expect(sessionFromRowPadding?.repo.id).toBe('repo-a') }) }) diff --git a/src/renderer/src/components/sidebar/project-header-drag-start.ts b/src/renderer/src/components/sidebar/project-header-drag-start.ts index 0a0429f1bf4..7ff51e20aee 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-start.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-start.ts @@ -14,8 +14,7 @@ import type { Repo } from '../../../../shared/repo-types' export function createProjectHeaderDragSession(args: { event: PointerEvent - repoId: string - repoById: ReadonlyMap + repo: Repo sidebarRepoHeaderIdsByBucket: ReadonlyMap getScrollContainer: () => HTMLElement | null }): ProjectHeaderDragSession | null { @@ -28,11 +27,7 @@ export function createProjectHeaderDragSession(args: { if (isRepoHeaderActionTarget(args.event.target, args.event.currentTarget)) { return null } - const repo = args.repoById.get(args.repoId) - if (!repo) { - return null - } - const bucketKey = getProjectHeaderDragBucketKey(repo) + const bucketKey = getProjectHeaderDragBucketKey(args.repo) const sidebarRepoHeaderIds = args.sidebarRepoHeaderIdsByBucket.get(bucketKey) ?? [] // Why: a single project in its bucket has nowhere to land, so skip arming // drag and let the header click toggle collapse instead. @@ -47,7 +42,7 @@ export function createProjectHeaderDragSession(args: { // Why: defer setPointerCapture until the drag threshold is crossed so a // header click still reaches the inner collapse handler on pointerup. return { - repoId: args.repoId, + repo: args.repo, bucketKey, sidebarRepoHeaderIds, pointerId: args.event.pointerId, diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 68ab299d616..2711e289018 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -17,6 +17,7 @@ import { createProjectHeaderDragSession } from './project-header-drag-start' import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' import { hasPointerBeenReleased } from './header-drag-pointer-release' import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' +import type { Repo } from '../../../../shared/repo-types' // Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by // react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire @@ -27,6 +28,7 @@ export function useRepoHeaderDrag({ orderedRepoIds, sidebarRepoHeaderIdsByBucket, repoById, + getLiveRepos, usesProjectGroupOrdering, onCommitRepoOrder, onCommitProjectGroupOrder, @@ -46,6 +48,8 @@ export function useRepoHeaderDrag({ usesProjectGroupOrderingRef.current = usesProjectGroupOrdering const onCommitRepoOrderRef = useRef(onCommitRepoOrder) onCommitRepoOrderRef.current = onCommitRepoOrder + // Read once: callers pass a stable store reader. + const getLiveReposRef = useRef(getLiveRepos) const onCommitProjectGroupOrderRef = useRef(onCommitProjectGroupOrder) onCommitProjectGroupOrderRef.current = onCommitProjectGroupOrder const getContainerRef = useRef(getScrollContainer) @@ -144,6 +148,7 @@ export function useRepoHeaderDrag({ sidebarDropIndex, orderedRepoIds: orderedIdsRef.current, repoById: repoByIdRef.current, + liveRepos: getLiveReposRef.current(), usesProjectGroupOrdering: usesProjectGroupOrderingRef.current, onCommitRepoOrder: onCommitRepoOrderRef.current, onCommitProjectGroupOrder: onCommitProjectGroupOrderRef.current @@ -177,7 +182,7 @@ export function useRepoHeaderDrag({ refreshHeaderRects() } - applyDrop(session.repoId, computeDrop(session.latestPointerY)) + applyDrop(session.repo.id, computeDrop(session.latestPointerY)) autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) }, @@ -227,10 +232,10 @@ export function useRepoHeaderDrag({ } } refreshHeaderRects() - setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null }) + setState({ draggingRepoId: session.repo.id, dropIndex: null, dropIndicatorY: null }) } refreshHeaderRects() - applyDrop(session.repoId, computeDrop(e.clientY)) + applyDrop(session.repo.id, computeDrop(e.clientY)) ensureAutoscroll() } const onPointerUp = (e: PointerEvent): void => { @@ -296,23 +301,19 @@ export function useRepoHeaderDrag({ } }, [state.draggingRepoId]) - const onHandlePointerDown = useCallback( - (event: React.PointerEvent, repoId: string) => { - const session = createProjectHeaderDragSession({ - event, - repoId, - repoById: repoByIdRef.current, - sidebarRepoHeaderIdsByBucket: sidebarRepoHeaderIdsByBucketRef.current, - getScrollContainer: getContainerRef.current - }) - if (!session) { - return - } - dragSessionRef.current = session - setSessionArmed(true) - }, - [] - ) + const onHandlePointerDown = useCallback((event: React.PointerEvent, repo: Repo) => { + const session = createProjectHeaderDragSession({ + event, + repo, + sidebarRepoHeaderIdsByBucket: sidebarRepoHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return + } + dragSessionRef.current = session + setSessionArmed(true) + }, []) return { state, onHandlePointerDown } } diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 234710d78bd..2daf7ca0dae 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -78,10 +78,9 @@ export function useWorktreeContextMenuCommands(args: { if (!args.repo) { return } - const hostId = getRepoExecutionHostId(args.repo) - const group = await args.createProjectGroup(name, hostId) + const group = await args.createProjectGroup(name, getRepoExecutionHostId(args.repo)) if (group) { - await args.moveProjectToGroup(args.repo.id, group.id, undefined, hostId) + await args.moveProjectToGroup(args.repo, group.id) } }, [args] @@ -91,18 +90,13 @@ export function useWorktreeContextMenuCommands(args: { if (!args.repo || args.repo.projectGroupId === groupId) { return } - void args.moveProjectToGroup( - args.repo.id, - groupId, - undefined, - getRepoExecutionHostId(args.repo) - ) + void args.moveProjectToGroup(args.repo, groupId) }, [args] ) const handleRemoveProjectFromGroup = useCallback(() => { if (args.repo) { - void args.moveProjectToGroup(args.repo.id, null, undefined, getRepoExecutionHostId(args.repo)) + void args.moveProjectToGroup(args.repo, null) } }, [args]) const handleAssignWorkspaceStatus = useCallback( diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts index d37069945fb..404c59b1d44 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts @@ -23,6 +23,8 @@ import { useProjectGroupHeaderDrag } from '../../project-group-header-drag' import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from '../../project-group-header-drop' import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from '../viewport/use-scroll-suppression' +const readLiveRepos = (): readonly Repo[] => useAppStore.getState().repos + export type WorktreeSidebarHeaderDrag = ReturnType function indexById(byBucket: ReadonlyMap): Map { @@ -160,8 +162,8 @@ export function useWorktreeSidebarHeaderDrag(args: { [sidebarProjectGroupHeaderIdsByBucket] ) const commitProjectGroupOrder = useCallback( - (repoId: string, projectGroupId: string | null, order: number, hostId: ExecutionHostId) => { - void moveProjectToGroup(repoId, projectGroupId, order, hostId) + (project: Repo, projectGroupId: string | null, order: number) => { + void moveProjectToGroup(project, projectGroupId, order) }, [moveProjectToGroup] ) @@ -182,6 +184,7 @@ export function useWorktreeSidebarHeaderDrag(args: { orderedRepoIds: allRepoIds, sidebarRepoHeaderIdsByBucket, repoById: repoMap, + getLiveRepos: readLiveRepos, usesProjectGroupOrdering: hasProjectGroups, onCommitRepoOrder: commitRepoReorder, onCommitProjectGroupOrder: commitProjectGroupOrder, diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index 1a11b00df84..a9ba7d4c73c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -94,7 +94,8 @@ export function renderWorktreeSectionHeaderRow(args: { const { headerDrag } = ctx const isRepoHeader = ctx.groupBy === 'repo' && row.repo !== undefined const isProjectGroupHeader = ctx.groupBy === 'repo' && row.projectGroup !== undefined - const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const headerRepo = isRepoHeader ? row.repo : undefined + const projectIdForHeader = headerRepo?.id const projectGroupIdForHeader = isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' ? row.projectGroup.id @@ -278,8 +279,8 @@ export function renderWorktreeSectionHeaderRow(args: { : undefined } onPointerDown={ - isDraggableRepoHeader && projectIdForHeader - ? (event) => headerDrag.repoDrag.onHandlePointerDown(event, projectIdForHeader) + isDraggableRepoHeader && headerRepo + ? (event) => headerDrag.repoDrag.onHandlePointerDown(event, headerRepo) : isDraggableProjectGroupHeader && projectGroupIdForHeader ? (event) => headerDrag.projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index c9c3a87b2ac..a7d4c1261e2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -89,14 +89,14 @@ export function useProjectGroupDialogs(args: { if (repo.projectGroupId === groupId) { return } - void moveProjectToGroup(repo.id, groupId, undefined, getRepoExecutionHostId(repo)) + void moveProjectToGroup(repo, groupId) }, [moveProjectToGroup] ) const handleRemoveProjectFromGroup = useCallback( (repo: Repo) => { - void moveProjectToGroup(repo.id, null, undefined, getRepoExecutionHostId(repo)) + void moveProjectToGroup(repo, null) }, [moveProjectToGroup] ) @@ -114,10 +114,9 @@ export function useProjectGroupDialogs(args: { return } if (nameDialog.type === 'create-from-repo') { - const hostId = getRepoExecutionHostId(nameDialog.repo) - const group = await createProjectGroup(name, hostId) + const group = await createProjectGroup(name, getRepoExecutionHostId(nameDialog.repo)) if (group) { - await moveProjectToGroup(nameDialog.repo.id, group.id, undefined, hostId) + await moveProjectToGroup(nameDialog.repo, group.id) } return } diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index 2ca879db89f..3d278f8406d 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -10,7 +10,11 @@ import { resolveProjectGroupOwnerHostId, runtimeTargetForProjectGroupOwner } from '../slices/project-group-owner-routing' -import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' +import { + findRepoForHost, + isSameRepoRow, + repoMatchesHostIdentity +} from '../slices/repo-host-identity' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { ipcOrRuntimeTargetForHostId } from '../../runtime/runtime-client-target' import { getRepoExecutionHostId } from '../../../../shared/execution-host' @@ -249,12 +253,12 @@ export function createProjectGroupMutationActions( } }, - moveProjectToGroup: async (projectId, groupId, order, hostId) => { + moveProjectToGroup: async (project, groupId, order) => { try { - const repo = findRepoForHost(get().repos, projectId, { settings: get().settings, hostId }) + const repo = get().repos.find((candidate) => isSameRepoRow(candidate, project)) const repoHostId = repo ? getRepoExecutionHostId(repo) : null const target = repoHostId ? ipcOrRuntimeTargetForHostId(repoHostId) : null - if (!repoHostId || !target) { + if (!repo || !repoHostId || !target) { return false } if (groupId && !isProjectGroupJoinableFromCatalog(get().projectGroups, groupId, target)) { @@ -264,7 +268,7 @@ export function createProjectGroupMutationActions( const moved = target.kind === 'local' ? await window.api.projectGroups.moveProject({ - projectId, + projectId: repo.id, groupId, order, hostId: repoHostId @@ -273,7 +277,8 @@ export function createProjectGroupMutationActions( await callRuntimeRpc<{ repo: Repo | null }>( target, 'projectGroup.moveProject', - { repo: projectId, groupId, order }, + // Why connectionId: the server's own twins differ only by their SSH target there. + { repo: repo.id, groupId, order, connectionId: repo.connectionId ?? null }, { timeoutMs: 15_000 } ) ).repo @@ -283,8 +288,8 @@ export function createProjectGroupMutationActions( const ownedMoved = adoptFromEndpoint(target, { kind: 'repo', row: moved }) const movedHostId = getRepoExecutionHostId(ownedMoved) set((s) => { - const nextRepos = s.repos.map((repo) => - repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo + const nextRepos = s.repos.map((candidate) => + isSameRepoRow(candidate, repo) ? ownedMoved : candidate ) return { repos: nextRepos, diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 3e59e7c0cc8..a8f04a47efb 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -22,6 +22,7 @@ import type { ProjectUpdateArgs } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' +import type { RepoHostTarget } from '../../../../shared/repo-host-identity' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest @@ -257,11 +258,11 @@ export type RepoSlice = { groupId: string, options: DeleteProjectGroupWithContainedProjectsOptions ) => Promise + // The row itself, not its id: twins share an id, and a paired server's twins share a host too. moveProjectToGroup: ( - projectId: string, + project: RepoHostTarget, groupId: string | null, - order?: number, - hostId?: ExecutionHostId + order?: number ) => Promise // options.hostId is required: a bare id can resolve to another host's row (#13071). Removes nothing when that host has no row. // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts index 140fe4ec8e1..026fe0ccce2 100644 --- a/src/renderer/src/store/slices/repo-host-identity.ts +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -3,6 +3,7 @@ import type { Repo } from '../../../../shared/repo-types' import { getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + normalizeHostPart, type ExecutionHostId } from '../../../../shared/execution-host' import { @@ -18,6 +19,14 @@ export function getRepoHostIdentity(repo: RepoIdentityParts): HostScopedRepoKey return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) } +/** One exact row: a paired server's twins share its `runtime:` host and differ by nested SSH target. */ +export function isSameRepoRow(left: RepoIdentityParts, right: RepoIdentityParts): boolean { + return ( + getRepoHostIdentity(left) === getRepoHostIdentity(right) && + normalizeHostPart(left.connectionId) === normalizeHostPart(right.connectionId) + ) +} + export function repoMatchesHostIdentity( repo: RepoIdentityParts, repoId: string, diff --git a/src/renderer/src/store/slices/repos-project-group-cross-host-move.test.ts b/src/renderer/src/store/slices/repos-project-group-cross-host-move.test.ts index 562d37e5200..8eb75342962 100644 --- a/src/renderer/src/store/slices/repos-project-group-cross-host-move.test.ts +++ b/src/renderer/src/store/slices/repos-project-group-cross-host-move.test.ts @@ -88,9 +88,9 @@ describe('moving a project into a group on another host', () => { it('refuses with a toast instead of sending a move that would ungroup the project', async () => { const store = storeWith([group('local-group', 'local'), group('remote-group', REMOTE_HOST)]) - await expect( - store.getState().moveProjectToGroup(remoteRepo.id, 'local-group', 0, REMOTE_HOST) - ).resolves.toBe(false) + await expect(store.getState().moveProjectToGroup(remoteRepo, 'local-group', 0)).resolves.toBe( + false + ) expect(moveCalls()).toEqual([]) expect(moveProject).not.toHaveBeenCalled() @@ -103,7 +103,7 @@ describe('moving a project into a group on another host', () => { const store = storeWith([group('shared', 'local'), group('shared', REMOTE_HOST)]) await expect( - store.getState().moveProjectToGroup(remoteRepo.id, 'shared', undefined, REMOTE_HOST) + store.getState().moveProjectToGroup(remoteRepo, 'shared', undefined) ).resolves.toBe(true) expect(moveCalls()).toHaveLength(1) @@ -116,7 +116,7 @@ describe('moving a project into a group on another host', () => { const store = storeWith([]) await expect( - store.getState().moveProjectToGroup(remoteRepo.id, 'unknown-group', undefined, REMOTE_HOST) + store.getState().moveProjectToGroup(remoteRepo, 'unknown-group', undefined) ).resolves.toBe(false) expect(moveCalls()).toHaveLength(1) @@ -128,9 +128,9 @@ describe('moving a project into a group on another host', () => { runtimeMoveReturns({ ...remoteRepo, projectGroupId: null }) const store = storeWith([group('remote-group', REMOTE_HOST)]) - await expect( - store.getState().moveProjectToGroup(remoteRepo.id, null, undefined, REMOTE_HOST) - ).resolves.toBe(true) + await expect(store.getState().moveProjectToGroup(remoteRepo, null, undefined)).resolves.toBe( + true + ) expect(toast.error).not.toHaveBeenCalled() }) @@ -147,10 +147,35 @@ describe('moving one twin of a repo id shared by a local and an SSH row', () => projectGroups: [group('ssh-group', 'ssh:ssh-1')] }) - await store.getState().moveProjectToGroup(remoteRepo.id, 'ssh-group', undefined, 'ssh:ssh-1') + await store.getState().moveProjectToGroup(sshTwin, 'ssh-group') expect(moveProject).toHaveBeenCalledWith( expect.objectContaining({ projectId: remoteRepo.id, hostId: 'ssh:ssh-1' }) ) }) }) + +describe('moving one twin of a repo id on a paired server (server-local and server-SSH rows)', () => { + it('names the twin by its server-side SSH target and updates only that row', async () => { + const serverLocalTwin: Repo = { ...remoteRepo, connectionId: null, projectGroupId: null } + const serverSshTwin: Repo = { ...remoteRepo, connectionId: 'srv-ssh', projectGroupId: null } + runtimeMoveReturns({ ...serverSshTwin, projectGroupId: 'remote-group' }) + const store = createTestStore() + store.setState({ + repos: [serverLocalTwin, serverSshTwin], + projectGroups: [group('remote-group', REMOTE_HOST)] + }) + + await expect(store.getState().moveProjectToGroup(serverSshTwin, 'remote-group')).resolves.toBe( + true + ) + + expect(moveCalls().map((request) => request.params)).toEqual([ + { repo: remoteRepo.id, groupId: 'remote-group', connectionId: 'srv-ssh' } + ]) + expect(store.getState().repos.map((repo) => repo.projectGroupId ?? null)).toEqual([ + null, + 'remote-group' + ]) + }) +}) diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts index fe5d6b7af52..69db37a890d 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -849,9 +849,9 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ repos: [remoteRepo], projectGroups: [projectGroup] }) - await expect( - store.getState().moveProjectToGroup(remoteRepo.id, projectGroup.id, 3) - ).resolves.toBe(true) + await expect(store.getState().moveProjectToGroup(remoteRepo, projectGroup.id, 3)).resolves.toBe( + true + ) expect(projectGroupsMoveProject).toHaveBeenCalledWith({ projectId: remoteRepo.id, diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 6aa6d636cb4..4125fdb888a 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -589,7 +589,11 @@ describe('repo slice runtime routing', () => { repos: [{ ...remoteRepo, executionHostId: 'runtime:env-1' }] }) - await expect(store.getState().moveProjectToGroup(remoteRepo.id, 'group-1')).resolves.toBe(true) + await expect( + store + .getState() + .moveProjectToGroup({ ...remoteRepo, executionHostId: 'runtime:env-1' }, 'group-1') + ).resolves.toBe(true) expect(store.getState().repos).toEqual([ { ...remoteRepo, projectGroupId: 'group-1', executionHostId: 'runtime:env-1' } @@ -597,7 +601,7 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'projectGroup.moveProject', - params: { repo: remoteRepo.id, groupId: 'group-1', order: undefined }, + params: { repo: remoteRepo.id, groupId: 'group-1', order: undefined, connectionId: null }, timeoutMs: 15_000 }) expect(projectGroupsMoveProject).not.toHaveBeenCalled() diff --git a/src/shared/rpc-contract/repo-params.ts b/src/shared/rpc-contract/repo-params.ts index e5d29174a03..bfd3e945394 100644 --- a/src/shared/rpc-contract/repo-params.ts +++ b/src/shared/rpc-contract/repo-params.ts @@ -62,10 +62,12 @@ export const ProjectGroupSelector = z.object({ groupId: requiredString('Missing group id') }) +// Why `connectionId` is optional: older clients omit it, and older hosts strip it (bare selector). export const ProjectGroupMoveProject = z.object({ repo: requiredString('Missing repo selector'), groupId: OptionalString.nullable(), - order: OptionalFiniteNumber + order: OptionalFiniteNumber, + connectionId: z.string().nullable().optional() }) export const ProjectGroupScanNested = z.object({ diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts index cdcaa022b1c..710b201ac61 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -126,7 +126,11 @@ for (const delayCreateResponse of [false, true]) { async ({ repoIds, groupId }) => { const store = window.__store! for (const repoId of repoIds.slice(0, 2)) { - await store.getState().moveProjectToGroup(repoId, groupId) + const repo = store.getState().repos.find((candidate) => candidate.id === repoId) + if (!repo) { + throw new Error(`Missing repo ${repoId}`) + } + await store.getState().moveProjectToGroup(repo, groupId) } const collapsedGroups = store .getState() diff --git a/tests/e2e/sidebar-measured-row-scroll.spec.ts b/tests/e2e/sidebar-measured-row-scroll.spec.ts index 2159ff6a523..bd6481b177f 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -127,7 +127,7 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( if (!group) { throw new Error('Fixture group was not created') } - await store.getState().moveProjectToGroup(member.id, group.id) + await store.getState().moveProjectToGroup(member, group.id) const state = store.getState() state.setGroupBy('repo') state.setProjectOrderBy('manual') diff --git a/tests/e2e/worktree-lineage-toggle-dialog.spec.ts b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts index 76ec5c7a153..8b21d1724b7 100644 --- a/tests/e2e/worktree-lineage-toggle-dialog.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts @@ -31,7 +31,11 @@ test('locally controlled project-group modal keeps child-toggle input', async ({ if (!group) { throw new Error('Missing project group') } - await store.getState().moveProjectToGroup(parent.repoId, group.id) + const parentRepo = store.getState().repos.find((repo) => repo.id === parent.repoId) + if (!parentRepo) { + throw new Error('Missing parent repo') + } + await store.getState().moveProjectToGroup(parentRepo, group.id) store.getState().setGroupBy('repo') await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) window.addEventListener('keydown', (event) => {