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
This commit is contained in:
Neil
2026-10-19 11:28:29 -07:00
committed by GitHub
parent 69157847e3
commit 86ef00ad55
26 changed files with 225 additions and 146 deletions
+1 -1
View File
@@ -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
@@ -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) =>
+3 -2
View File
@@ -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,
+6 -1
View File
@@ -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
)
})
@@ -28,7 +28,8 @@ import {
type RuntimeProjectGroupDependencies = {
getStore: () => RuntimeStore | null
resolveRepo: (selector: string) => Promise<Repo>
// `connectionId` narrows twins by their SSH target; undefined keeps the plain selector.
resolveRepo: (selector: string, connectionId?: string | null) => Promise<Repo>
notifyReposChanged: () => void
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise<void>
@@ -128,12 +129,17 @@ export class RuntimeProjectGroupController {
return { deleted }
}
async moveProject(repoSelector: string, groupId: string | null, order?: number): Promise<Repo> {
async moveProject(
repoSelector: string,
groupId: string | null,
order?: number,
connectionId?: string | null
): Promise<Repo> {
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) {
@@ -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', () => {
@@ -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> = {}): Repo {
@@ -17,12 +18,12 @@ function makeRepo(id: string, overrides: Partial<Repo> = {}): 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()
})
})
@@ -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<string, Repo>
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
}
@@ -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<ProjectHeaderDragBucketKey, readonly string[]>
repoById: ReadonlyMap<string, Repo>
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<HTMLElement>, repoId: string) => void
onHandlePointerDown: (event: PointerEvent<HTMLElement>, 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
@@ -24,7 +24,6 @@ describe('createProjectHeaderDragSession', () => {
const scrollContainer = document.createElement('div')
document.body.append(scrollContainer, handleEl)
const repoById = new Map<string, Repo>([['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<HTMLElement>,
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<string, Repo>([['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<HTMLElement>,
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<string, Repo>([['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<HTMLElement>,
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<string, Repo>([['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<HTMLElement>,
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<string, Repo>([['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<HTMLElement>,
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<HTMLElement>,
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<HTMLElement>,
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')
})
})
@@ -14,8 +14,7 @@ import type { Repo } from '../../../../shared/repo-types'
export function createProjectHeaderDragSession(args: {
event: PointerEvent<HTMLElement>
repoId: string
repoById: ReadonlyMap<string, Repo>
repo: Repo
sidebarRepoHeaderIdsByBucket: ReadonlyMap<ProjectHeaderDragBucketKey, readonly string[]>
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,
@@ -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<HTMLElement>, 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<HTMLElement>, repo: Repo) => {
const session = createProjectHeaderDragSession({
event,
repo,
sidebarRepoHeaderIdsByBucket: sidebarRepoHeaderIdsByBucketRef.current,
getScrollContainer: getContainerRef.current
})
if (!session) {
return
}
dragSessionRef.current = session
setSessionArmed(true)
}, [])
return { state, onHandlePointerDown }
}
@@ -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(
@@ -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<typeof useWorktreeSidebarHeaderDrag>
function indexById(byBucket: ReadonlyMap<string, readonly string[]>): Map<string, number> {
@@ -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,
@@ -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)
@@ -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
}
@@ -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,
+4 -3
View File
@@ -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<DeleteProjectGroupWithContainedProjectsResult>
// 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<boolean>
// 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.
@@ -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,
@@ -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'
])
})
})
@@ -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,
+6 -2
View File
@@ -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()
+3 -1
View File
@@ -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({
@@ -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()
@@ -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')
@@ -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) => {