fix(sidebar): a status or order drag writes the card you dragged, not its twin on another host (#30844)

The drag carried bare worktree ids, and the drop read each id's host from the
id-keyed worktree map, which keeps one row per id. When the same worktree is
published by two hosts (a local and an SSH copy), dragging one card could set
the status or manual order on the other host's copy.

The pointer and native drag sessions now record the dragged cards, and their
visible lineage children, as (id, host) copies; the lineage expansion runs by
host identity so a twin's children are not pulled in. The shared drop-commit
context hands the copies to every status, reorder and board-drop commit.

Every write then resolves through one choke point, dropWriteRows: a dragged id
writes only the dragged copies (read fresh; a copy gone by drop time is
refused), and any other id (a re-ranked sibling, a drop with no drag session)
only when a single host publishes it. The board drop indicator reads the same
rows, so the preview matches the commit.
This commit is contained in:
Neil
2026-10-19 14:47:02 -07:00
committed by GitHub
parent a201eacd06
commit 1529b0f73b
23 changed files with 709 additions and 212 deletions
@@ -145,7 +145,6 @@ const WorktreeList = React.memo(function WorktreeList({
})
const statusMutations = useWorktreeStatusMutations({
manualOrderCatalog,
worktreeMap,
workspaceStatuses,
sortBy
})
@@ -1,3 +1,4 @@
import { draggedWorktreeCopiesOf } from './worktree-list/drag/dragged-worktree-copies'
import React, { useCallback } from 'react'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
@@ -133,14 +134,18 @@ export function useWorktreeCardWorkspaceActions({
event.preventDefault()
return
}
const dragIds =
isMultiSelected && selectedWorktrees && selectedWorktrees.length > 1
? selectedWorktrees.map((item) => item.id)
: worktree.id
writeWorkspaceDragData(event.dataTransfer, dragIds)
onCardDragStart?.(event, worktree.id, Array.isArray(dragIds) ? dragIds : [dragIds])
const draggedCopies = draggedWorktreeCopiesOf(
worktree,
selectedWorktrees,
Boolean(isMultiSelected && selectedWorktrees && selectedWorktrees.length > 1)
)
writeWorkspaceDragData(
event.dataTransfer,
draggedCopies.map((copy) => copy.id)
)
onCardDragStart?.(event, worktree.id, draggedCopies)
},
[isDeleting, isMultiSelected, onCardDragStart, selectedWorktrees, worktree.id]
[isDeleting, isMultiSelected, onCardDragStart, selectedWorktrees, worktree]
)
const handleDragEnd = useCallback(
@@ -550,6 +550,14 @@ describe('workspace kanban sidebar drop DOM bridge', () => {
})
})
function rowsFrom(byId: ReadonlyMap<string, Worktree>) {
const rowsFor = (worktreeId: string): Worktree[] => {
const row = byId.get(worktreeId)
return row ? [row] : []
}
return { status: rowsFor, order: rowsFor }
}
describe('workspace kanban sidebar drop updates', () => {
it('writes a status-only update for cross-lane drops outside Manual sort', () => {
const worktreeById = new Map([
@@ -565,7 +573,7 @@ describe('workspace kanban sidebar drop updates', () => {
{ key: 'todo', worktreeIds: ['todo-a'] },
{ key: 'doing', worktreeIds: ['doing-a'] }
],
worktreeById,
rowsFor: rowsFrom(worktreeById),
allWorktreeIds: [...worktreeById.keys()],
rankByWorktreeId: new Map(),
workspaceStatuses,
@@ -594,7 +602,7 @@ describe('workspace kanban sidebar drop updates', () => {
{ key: 'todo', worktreeIds: ['todo-a'] },
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b'] }
],
worktreeById,
rowsFor: rowsFrom(worktreeById),
allWorktreeIds: [...worktreeById.keys()],
rankByWorktreeId: new Map(),
workspaceStatuses,
@@ -660,7 +668,7 @@ describe('workspace kanban sidebar drop updates', () => {
{ key: 'todo', worktreeIds: ['todo-a'] },
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b'] }
],
worktreeById,
rowsFor: rowsFrom(worktreeById),
allWorktreeIds: ['todo-a', 'filtered', 'doing-a', 'doing-b'],
rankByWorktreeId,
workspaceStatuses,
@@ -1,14 +1,10 @@
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type {
WorkspaceStatus,
WorkspaceStatusDefinition,
Worktree
} from '../../../../shared/worktree/types'
import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/worktree/types'
import {
parseWorkspaceLaneFullIds,
resolveFullLaneDropIndex
} from './workspace-kanban-filtered-drop-index'
import { getWorkspaceStatus } from './workspace-status'
import { collectDropWrites, type DropWriteRows } from './worktree-list/drag/dragged-worktree-copies'
import {
buildManualOrderUpdatesForGroupDrop,
shouldWriteManualOrderForGroupDrop,
@@ -215,7 +211,8 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: {
status: WorkspaceStatus
dropIndex: number
groups: readonly WorktreeDragGroup[]
worktreeById: ReadonlyMap<string, Worktree>
/** The rows each id may write; a twin's id names another host's copy too. */
rowsFor: DropWriteRows
allWorktreeIds: readonly string[]
rankByWorktreeId: ReadonlyMap<string, number>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
@@ -225,10 +222,9 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: {
updates: WorktreeMetaBatchUpdate[]
shouldSwitchToManual: boolean
} {
const sourceGroupKeys = args.worktreeIds.flatMap((worktreeId) => {
const worktree = args.worktreeById.get(worktreeId)
return worktree ? [getWorkspaceStatus(worktree, args.workspaceStatuses)] : []
})
const sourceGroupKeys = args.worktreeIds.flatMap((worktreeId) =>
args.rowsFor.status(worktreeId).map((row) => getWorkspaceStatus(row, args.workspaceStatuses))
)
const writeManualOrder = shouldWriteManualOrderForGroupDrop({
sortBy: args.sortBy,
sourceGroupKeys,
@@ -246,41 +242,14 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: {
})
: { changed: false, updates: new Map<string, { manualOrder: number }>() }
const updates: WorktreeMetaBatchUpdate[] = []
for (const worktreeId of args.worktreeIds) {
const current = args.worktreeById.get(worktreeId)
if (!current) {
continue
}
const next: Partial<WorktreeMeta> = {}
if (getWorkspaceStatus(current, args.workspaceStatuses) !== args.status) {
next.workspaceStatus = args.status
}
updates.push({
worktreeId,
updates: next,
executionHostId: current.hostId ?? 'local'
})
}
if (writeManualOrder) {
for (const [worktreeId, manualOrder] of order.updates) {
const current = args.worktreeById.get(worktreeId)
const entry = updates.find((candidate) => candidate.worktreeId === worktreeId)
if (entry) {
entry.updates = { ...entry.updates, ...manualOrder }
} else if (current) {
updates.push({
worktreeId,
updates: manualOrder,
executionHostId: current.hostId ?? 'local'
})
}
}
}
return {
updates: updates.filter((entry) => Object.keys(entry.updates).length > 0),
updates: collectDropWrites({
rowsFor: args.rowsFor,
statusIds: args.worktreeIds,
status: args.status,
workspaceStatuses: args.workspaceStatuses,
orderUpdates: order.updates
}),
shouldSwitchToManual: writeManualOrder && order.changed
}
}
@@ -1,3 +1,4 @@
import type { DraggedWorktreeCopies } from './worktree-list/drag/dragged-worktree-copies'
import type React from 'react'
import type { Repo } from '../../../../shared/repo-types'
@@ -49,7 +50,7 @@ export type WorktreeCardProps = {
onCardDragStart?: (
event: React.DragEvent<HTMLDivElement>,
worktreeId: string,
draggedIds: readonly string[]
draggedCopies: DraggedWorktreeCopies
) => void
onCardDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void
nativeDragEnabled?: boolean
@@ -0,0 +1,98 @@
import type {
WorkspaceStatus,
WorkspaceStatusDefinition,
Worktree
} from '../../../../../../shared/worktree/types'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { AppState } from '../../../../store/types'
import type { WorktreeMetaBatchUpdate } from '../../../../store/slices/worktree-helpers'
import { getAllWorktreesFromState } from '../../../../store/selectors'
import { getWorkspaceStatus } from '../../workspace-status'
/** The cards a drag picked up, by host: a twin on another host shares each card's id. */
export type DraggedWorktreeCopies = readonly Pick<Worktree, 'id' | 'hostId'>[]
export function draggedWorktreeCopiesOf(
pressed: Worktree,
selected: readonly Worktree[] | undefined,
useSelection: boolean
): DraggedWorktreeCopies {
return (useSelection && selected ? selected : [pressed]).map(({ id, hostId }) => ({ id, hostId }))
}
export function uniqueDraggedIds(copies: DraggedWorktreeCopies): string[] {
return [...new Set(copies.map((copy) => copy.id))]
}
/** Which rows a drop may write for an id; empty means refuse, never guess a host. */
export type DropWriteRows = {
/** Status is each card's own: the dragged copies (read fresh), else the id's only row. */
status: (worktreeId: string) => readonly Worktree[]
/** Manual order is ranked per id (the catalog drops a rank its copies disagree on), so every copy. */
order: (worktreeId: string) => readonly Worktree[]
}
export function dropWriteRows(
state: Pick<AppState, 'worktreesByRepo' | 'folderWorkspaces'>,
copies: DraggedWorktreeCopies | undefined
): DropWriteRows {
const rowsById = new Map<string, Worktree[]>()
// Folder workspaces share the manual-order catalog, so a drop ranks them too.
for (const row of [
...getAllWorktreesFromState(state),
...state.folderWorkspaces.map(folderWorkspaceToWorktree)
]) {
rowsById.set(row.id, [...(rowsById.get(row.id) ?? []), row])
}
const dragged = new Set((copies ?? []).map(getWorktreeHostIdentity))
const draggedIds = new Set((copies ?? []).map((copy) => copy.id))
return {
status: (worktreeId) => {
const rows = rowsById.get(worktreeId) ?? []
if (draggedIds.has(worktreeId)) {
return rows.filter((row) => dragged.has(getWorktreeHostIdentity(row)))
}
return rows.length === 1 ? rows : []
},
order: (worktreeId) => rowsById.get(worktreeId) ?? []
}
}
/** The one place a drop turns status and rank changes into per-row writes, each on its row's host. */
export function collectDropWrites(args: {
rowsFor: DropWriteRows
statusIds: readonly string[]
status: WorkspaceStatus | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
orderUpdates: ReadonlyMap<string, { manualOrder: number }>
}): WorktreeMetaBatchUpdate[] {
const writes = new Map<string, WorktreeMetaBatchUpdate>()
const write = (row: Worktree, updates: Partial<WorktreeMeta>): void => {
const key = getWorktreeHostIdentity(row)
const entry = writes.get(key) ?? {
worktreeId: row.id,
updates: {},
executionHostId: row.hostId ?? 'local'
}
entry.updates = { ...entry.updates, ...updates }
writes.set(key, entry)
}
const { status } = args
if (status !== null) {
for (const worktreeId of args.statusIds) {
for (const row of args.rowsFor.status(worktreeId)) {
if (getWorkspaceStatus(row, args.workspaceStatuses) !== status) {
write(row, { workspaceStatus: status })
}
}
}
}
for (const [worktreeId, manualOrder] of args.orderUpdates) {
for (const row of args.rowsFor.order(worktreeId)) {
write(row, manualOrder)
}
}
return [...writes.values()]
}
@@ -8,6 +8,7 @@ import type { WorktreeDragUnitGroup } from '../../worktree-drag-units'
import type { WorktreeSidebarDropPreview } from '../../worktree-sidebar-drop-preview'
import type { WorktreeStatusDropRequest } from './use-session'
import type { WorktreeSidebarLineageDropTarget } from './row-state'
import type { DraggedWorktreeCopies } from './dragged-worktree-copies'
export const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {}
@@ -16,6 +17,21 @@ export type WorktreeStatusDropAtIndexArgs = {
status: WorkspaceStatus
dropIndex: number
groups: readonly WorktreeDragGroup[]
draggedCopies?: DraggedWorktreeCopies
}
export type ShouldShowWorkspaceBoardDropIndicator = (
worktreeIds: readonly string[],
status: WorkspaceStatus,
draggedCopies?: DraggedWorktreeCopies
) => boolean
export type WorktreeReorderArgs = {
groups: readonly WorktreeDragGroup[]
sourceGroupKey: string
draggedIds: readonly string[]
dropIndex: number
draggedCopies?: DraggedWorktreeCopies
}
// The shared surface every drop path (pointer, native drag, document capture) commits through.
@@ -39,13 +55,12 @@ export type WorktreeDropCommitContext = {
sourceGroupKey: string
}) => void
clearWorktreeDrag: () => void
onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onMoveWorktreesToStatus: (
worktreeIds: readonly string[],
status: WorkspaceStatus,
draggedCopies?: DraggedWorktreeCopies
) => void
onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void
onReorderWorktrees: (args: {
groups: readonly WorktreeDragGroup[]
sourceGroupKey: string
draggedIds: readonly string[]
dropIndex: number
}) => void
onReorderWorktrees: (args: WorktreeReorderArgs) => void
onPinWorktrees: (worktreeIds: readonly string[]) => void
}
@@ -70,6 +70,7 @@ export function commitWorktreePointerDrop(args: PointerDropCommitArgs): void {
} else if (boardDropTarget.status) {
args.onDropWorktreesOnWorkspaceBoard({
worktreeIds: drag.reorderDraggedIds,
draggedCopies: drag.draggedCopies,
status: boardDropTarget.status,
// Why: the target counts rendered cards, but the groups are the full
// lane. Board search can make those two differ.
@@ -42,6 +42,7 @@ function setup() {
currentY: 300,
worktreeId: 'child',
draggedIds: ['child'],
draggedCopies: [],
reorderDraggedIds: ['child'],
reorderUnitDraggedIds: ['child'],
sourceGroupKey: 'repo',
@@ -8,7 +8,10 @@ import {
} from '../../workspace-kanban-sidebar-drop'
import { updateSidebarDragPreviewPosition } from '../../worktree-sidebar-pointer-drag-dom'
import { getPointerDropStatusTarget, shouldPreferSidebarStatusDropTarget } from './status-target'
import type { WorktreeDropCommitContext } from './drop-commit-context'
import type {
ShouldShowWorkspaceBoardDropIndicator,
WorktreeDropCommitContext
} from './drop-commit-context'
import {
applyWorktreeDropPreview,
applyWorktreeLineageDropPreview,
@@ -28,10 +31,7 @@ export type WorktreePointerDragFrameArgs = {
workspaceBoardOpen: boolean
onWorkspaceBoardDragPreviewStart: () => void
onWorkspaceBoardDragPreviewCommit: () => void
shouldShowWorkspaceBoardDropIndicator: (
worktreeIds: readonly string[],
status: WorkspaceStatus
) => boolean
shouldShowWorkspaceBoardDropIndicator: ShouldShowWorkspaceBoardDropIndicator
setWorktreeDragState: React.Dispatch<React.SetStateAction<WorktreeRowDragState>>
setDragOverStatus: (status: WorkspaceStatus | null) => void
setPinDragOver: (pinDragOver: boolean) => void
@@ -113,7 +113,11 @@ export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs
shouldShowDropIndicator: (target) =>
Boolean(
target.status &&
args.shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status)
args.shouldShowWorkspaceBoardDropIndicator(
drag.reorderDraggedIds,
target.status,
drag.draggedCopies
)
)
})
drag.latestBoardDropTarget = {
@@ -1,3 +1,4 @@
import type { DraggedWorktreeCopies } from './dragged-worktree-copies'
import type { WorkspaceKanbanCardTrackedDropTarget } from '../../workspace-kanban-card-pointer-drag-dom'
import type { WorktreeSidebarDragRect } from '../../worktree-sidebar-drag-autoscroll'
import type {
@@ -37,6 +38,7 @@ export type WorktreePointerDrag = {
currentY: number
worktreeId: string
draggedIds: readonly string[]
draggedCopies: DraggedWorktreeCopies
reorderDraggedIds: readonly string[]
reorderUnitDraggedIds: readonly string[]
sourceGroupKey: string
@@ -0,0 +1,363 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useAppStore } from '@/store'
import { getWorktreeMapFromState } from '@/store/selectors'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../../shared/workspace-status-defaults'
import { makeFolderWorkspace, makeWorktree } from '../../../worktree-jump-palette-test-fixtures'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { buildWorktreeManualOrderCatalog } from '../../worktree-manual-order-catalog'
import { useWorktreeStatusMutations } from './use-status-mutations'
import { useWorktreeDropCommitContext } from './use-drop-commit-context'
import { useWorktreeDragSession } from './use-session'
import type { WorktreeDropCommitContext } from './drop-commit-context'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), dismiss: vi.fn() } }))
const REPO_ID = 'repo-twin'
const TWIN_ID = `${REPO_ID}::/src/app`
const OTHER_ID = `${REPO_ID}::/src/other`
const SSH: ExecutionHostId = 'ssh:build-box'
const initialState = useAppStore.getInitialState()
const updateWorktreesMeta = vi.fn().mockResolvedValue(undefined)
// The same worktree id published by this computer and by an SSH host: two sidebar cards.
function seedTwins(extra: Worktree[] = []): { local: Worktree; ssh: Worktree; dragged: Worktree } {
const local = makeWorktree(TWIN_ID, 'app', { repoId: REPO_ID, hostId: 'local' })
const ssh = makeWorktree(TWIN_ID, 'app', { repoId: REPO_ID, hostId: SSH })
const other = makeWorktree(OTHER_ID, 'other', { repoId: REPO_ID, hostId: 'local' })
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [local, other, ssh, ...extra] },
updateWorktreesMeta
})
// Drag whichever copy the id-keyed map did not keep, so a by-id lookup picks the wrong one.
const kept = getWorktreeMapFromState(useAppStore.getState()).get(TWIN_ID)
return { local, ssh, dragged: kept?.hostId === SSH ? local : ssh }
}
function renderMutations(sortBy: 'manual' | 'smart' = 'manual') {
const worktrees = Object.values(useAppStore.getState().worktreesByRepo).flat()
return renderHook(() =>
useWorktreeStatusMutations({
manualOrderCatalog: buildWorktreeManualOrderCatalog({ worktrees, folderWorkspaces: [] }),
workspaceStatuses: DEFAULT_WORKSPACE_STATUSES,
sortBy
})
).result
}
function written(): {
worktreeId: string
updates?: Record<string, unknown>
executionHostId?: string
}[] {
return updateWorktreesMeta.mock.calls[0]?.[0] ?? []
}
function writtenHosts(worktreeId = TWIN_ID, field?: 'workspaceStatus' | 'manualOrder') {
return written()
.filter((u) => u.worktreeId === worktreeId && (!field || field in (u.updates ?? {})))
.map((u) => u.executionHostId)
.sort()
}
const copyOf = (row: Worktree) => [{ id: row.id, hostId: row.hostId }]
describe('a sidebar drop on one card of a twin', () => {
beforeEach(() => {
useAppStore.setState(initialState, true)
updateWorktreesMeta.mockClear()
})
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
it('writes the status to the dragged copy, not the twin the id-keyed map kept', async () => {
const { dragged } = seedTwins()
const mutations = renderMutations()
await act(async () => {
mutations.current.moveWorktreesToStatusAtIndex({
worktreeIds: [TWIN_ID],
status: 'in-review',
dropIndex: 0,
groups: [{ key: 'in-review', worktreeIds: [] }],
draggedCopies: copyOf(dragged)
})
})
expect(writtenHosts(TWIN_ID, 'workspaceStatus')).toEqual([dragged.hostId])
})
// Manual order is ranked per id: the catalog drops a rank the copies disagree on.
it("writes a dragged twin's new rank to every copy so the id keeps one rank", async () => {
const { dragged } = seedTwins()
const mutations = renderMutations()
await act(async () => {
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [OTHER_ID, TWIN_ID] }],
sourceGroupKey: 'all',
draggedIds: [TWIN_ID],
dropIndex: 0,
draggedCopies: copyOf(dragged)
})
})
expect(writtenHosts(TWIN_ID, 'manualOrder')).toEqual(['local', SSH])
})
it('re-ranks every copy of a twin sibling, not whichever one the id-keyed map kept', async () => {
seedTwins()
const mutations = renderMutations()
const other = useAppStore.getState().worktreesByRepo[REPO_ID]!.find((w) => w.id === OTHER_ID)!
await act(async () => {
// Drag the unique row between the twin's cards; the twin is only re-ranked, not dragged.
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [TWIN_ID, OTHER_ID] }],
sourceGroupKey: 'all',
draggedIds: [OTHER_ID],
dropIndex: 0,
draggedCopies: copyOf(other)
})
})
expect(writtenHosts(TWIN_ID, 'manualOrder')).toEqual(['local', SSH])
expect(writtenHosts(OTHER_ID)).toEqual(['local'])
})
it('still ranks an unranked folder workspace that shares the manual-order catalog', async () => {
const folder = makeFolderWorkspace({ id: 'folder-unranked' })
const a = makeWorktree(`${REPO_ID}::/a`, 'a', { repoId: REPO_ID, hostId: 'local' })
const b = makeWorktree(`${REPO_ID}::/b`, 'b', { repoId: REPO_ID, hostId: 'local' })
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [a, b] },
folderWorkspaces: [folder],
updateWorktreesMeta
})
const catalog = buildWorktreeManualOrderCatalog({
worktrees: [a, b],
folderWorkspaces: [folder]
})
const { result } = renderHook(() =>
useWorktreeStatusMutations({
manualOrderCatalog: catalog,
workspaceStatuses: DEFAULT_WORKSPACE_STATUSES,
sortBy: 'manual'
})
)
await act(async () => {
result.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [a.id, b.id] }],
sourceGroupKey: 'all',
draggedIds: [b.id],
dropIndex: 0,
draggedCopies: copyOf(b)
})
})
expect(written().map((u) => u.worktreeId)).toContain(folderWorkspaceKey(folder.id))
})
it('lands a drop next to an unranked twin when the ranks are first materialized', async () => {
const b = makeWorktree(`${REPO_ID}::/b`, 'b', {
repoId: REPO_ID,
hostId: 'local',
sortOrder: 2
})
const a = makeWorktree(`${REPO_ID}::/a`, 'a', {
repoId: REPO_ID,
hostId: 'local',
sortOrder: 1
})
const aOnSsh = { ...a, hostId: SSH }
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [b, a, aOnSsh] },
updateWorktreesMeta
})
const mutations = renderMutations()
await act(async () => {
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [b.id, a.id] }],
sourceGroupKey: 'all',
draggedIds: [b.id],
dropIndex: 2,
draggedCopies: copyOf(b)
})
})
const applied = [b, a, aOnSsh].map((row) => {
const write = written().find(
(u) => u.worktreeId === row.id && u.executionHostId === row.hostId
)
return write ? { ...row, ...write.updates } : row
})
expect(
buildWorktreeManualOrderCatalog({ worktrees: applied, folderWorkspaces: [] }).orderedIds
).toEqual([a.id, b.id])
})
it('ranks a selected folder workspace dragged together with a git card', async () => {
const folder = makeFolderWorkspace({ id: 'folder-selected', executionHostId: 'local' })
const a = makeWorktree(`${REPO_ID}::/a`, 'a', { repoId: REPO_ID, hostId: 'local' })
const b = makeWorktree(`${REPO_ID}::/b`, 'b', { repoId: REPO_ID, hostId: 'local' })
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [a, b] },
folderWorkspaces: [folder],
updateWorktreesMeta
})
const folderId = folderWorkspaceKey(folder.id)
const mutations = renderMutations()
await act(async () => {
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [a.id, b.id, folderId] }],
sourceGroupKey: 'all',
draggedIds: [b.id, folderId],
dropIndex: 0,
draggedCopies: [...copyOf(b), { id: folderId, hostId: 'local' }]
})
})
expect(written().map((u) => u.worktreeId)).toContain(folderId)
})
it('refuses a dragged copy that left its host mid-drag rather than writing its twin', async () => {
const { dragged } = seedTwins()
useAppStore.setState({
worktreesByRepo: {
[REPO_ID]: useAppStore.getState().worktreesByRepo[REPO_ID]!.filter((w) => w !== dragged)
}
})
const mutations = renderMutations()
await act(async () => {
mutations.current.moveWorktreesToStatus([TWIN_ID], 'in-review', copyOf(dragged))
})
expect(updateWorktreesMeta).not.toHaveBeenCalled()
})
it("carries a dragged twin's own lineage children, with their host, not the other copy's", () => {
const { dragged } = seedTwins()
const twinOf: ExecutionHostId = dragged.hostId === SSH ? 'local' : SSH
const childOnDraggedHost = makeWorktree(`${REPO_ID}::/src/child-a`, 'child-a', {
repoId: REPO_ID,
hostId: dragged.hostId
})
const childOfTwin = makeWorktree(`${REPO_ID}::/src/child-b`, 'child-b', {
repoId: REPO_ID,
hostId: twinOf
})
const twin = { ...dragged, hostId: twinOf }
const item = (worktree: Worktree, depth: number): HostSectionRow => ({
type: 'item',
rowKey: `${worktree.hostId}|${worktree.id}`,
sectionKey: 'all',
worktree,
repo: undefined,
depth,
groupDepth: 0,
isLastLineageChild: depth > 0,
lineageChildCount: depth === 0 ? 1 : 0
})
const rows = [
item(dragged, 0),
item(childOnDraggedHost, 1),
item(twin, 0),
item(childOfTwin, 1)
]
const { result } = renderHook(() =>
useWorktreeDragSession({ rows, scrollRef: { current: null } })
)
expect(result.current.getReorderDraggedCopies(copyOf(dragged))).toEqual([
{ id: dragged.id, hostId: dragged.hostId },
{ id: childOnDraggedHost.id, hostId: dragged.hostId }
])
})
it('previews the board insertion line from the dragged copy, as the commit reads it', () => {
seedTwins()
const kept = getWorktreeMapFromState(useAppStore.getState()).get(TWIN_ID)
// The kept twin already sits in the target lane; the dragged copy does not.
useAppStore.setState({
sortBy: 'smart',
worktreesByRepo: {
[REPO_ID]: useAppStore
.getState()
.worktreesByRepo[REPO_ID]!.map((row) =>
row === kept
? { ...row, workspaceStatus: 'in-review' }
: { ...row, workspaceStatus: 'todo' }
)
}
})
const dragged = useAppStore
.getState()
.worktreesByRepo[REPO_ID]!.find((row) => row.id === TWIN_ID && row.hostId !== kept?.hostId)!
const mutations = renderMutations('smart')
expect(
mutations.current.shouldShowWorkspaceBoardDropIndicator(
[TWIN_ID],
'in-review',
copyOf(dragged)
)
).toBe(false)
})
it('hands the drop the copies the active drag picked up', () => {
const onMoveWorktreesToStatusAtIndex = vi.fn()
const onReorderWorktrees = vi.fn()
const onMoveWorktreesToStatus = vi.fn()
const draggedCopies = [{ id: TWIN_ID, hostId: SSH }] as const
const { result } = renderHook(() =>
useWorktreeDropCommitContext({
scrollRef: { current: null },
workspaceStatuses: [],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the context reads only the drag-session ref and pass-through fields here.
session: { worktreeDragSessionRef: { current: { draggedCopies } } } as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, only the pointer-drag ref is read.
lineageDrop: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, only the pointer-drag ref is read.
runtime: { worktreePointerDragRef: { current: null } } as never,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onReorderWorktrees,
onPinWorktrees: vi.fn()
})
)
const ctx: WorktreeDropCommitContext = result.current
ctx.onMoveWorktreesToStatus([TWIN_ID], 'done')
ctx.onMoveWorktreesToStatusAtIndex({
worktreeIds: [TWIN_ID],
status: 'done',
dropIndex: 0,
groups: []
})
ctx.onReorderWorktrees({
groups: [],
sourceGroupKey: 'all',
draggedIds: [TWIN_ID],
dropIndex: 0
})
expect(onMoveWorktreesToStatus).toHaveBeenCalledWith([TWIN_ID], 'done', draggedCopies)
expect(onMoveWorktreesToStatusAtIndex.mock.calls[0][0].draggedCopies).toBe(draggedCopies)
expect(onReorderWorktrees.mock.calls[0][0].draggedCopies).toBe(draggedCopies)
})
})
@@ -24,8 +24,13 @@ export function useWorktreeDropCommitContext(args: {
onReorderWorktrees,
onPinWorktrees
} = args
return useMemo<WorktreeDropCommitContext>(
() => ({
const { worktreePointerDragRef } = runtime
const { worktreeDragSessionRef } = session
return useMemo<WorktreeDropCommitContext>(() => {
// Why: read at drop time; the drops pass ids, which name a twin's card on another host too.
const draggedCopies = () =>
worktreePointerDragRef.current?.draggedCopies ?? worktreeDragSessionRef.current?.draggedCopies
return {
scrollRef,
workspaceStatuses,
worktreeDragGroups: session.worktreeDragGroups,
@@ -37,27 +42,31 @@ export function useWorktreeDropCommitContext(args: {
commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop,
clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents,
clearWorktreeDrag: runtime.clearWorktreeDrag,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onReorderWorktrees,
onMoveWorktreesToStatus: (worktreeIds, status) =>
onMoveWorktreesToStatus(worktreeIds, status, draggedCopies()),
onMoveWorktreesToStatusAtIndex: (drop) =>
onMoveWorktreesToStatusAtIndex({ ...drop, draggedCopies: draggedCopies() }),
onReorderWorktrees: (reorder) =>
onReorderWorktrees({ ...reorder, draggedCopies: draggedCopies() }),
onPinWorktrees
}),
[
lineageDrop.clearReorderedWorktreeParents,
lineageDrop.commitWorktreeLineageParentDrop,
lineageDrop.getEligibleLineageDropTarget,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onPinWorktrees,
onReorderWorktrees,
runtime.clearWorktreeDrag,
scrollRef,
session.computeWorktreeDrop,
session.computeWorktreeStatusDrop,
session.refreshWorktreeDragSession,
session.worktreeDragGroups,
session.worktreeDragUnitGroups,
workspaceStatuses
]
)
}
}, [
lineageDrop.clearReorderedWorktreeParents,
lineageDrop.commitWorktreeLineageParentDrop,
lineageDrop.getEligibleLineageDropTarget,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onPinWorktrees,
onReorderWorktrees,
runtime.clearWorktreeDrag,
scrollRef,
session.computeWorktreeDrop,
session.computeWorktreeStatusDrop,
session.refreshWorktreeDragSession,
session.worktreeDragGroups,
session.worktreeDragUnitGroups,
workspaceStatuses,
worktreeDragSessionRef,
worktreePointerDragRef
])
}
@@ -1,3 +1,4 @@
import { uniqueDraggedIds, type DraggedWorktreeCopies } from './dragged-worktree-copies'
import { useCallback } from 'react'
import type React from 'react'
import { getWorkspaceKanbanSidebarDropTarget } from '../../workspace-kanban-sidebar-drop'
@@ -41,13 +42,19 @@ export function useWorktreeNativeDrag(args: {
})
const handleWorktreeCardDragStart = useCallback(
(event: React.DragEvent<HTMLDivElement>, worktreeId: string, draggedIds: readonly string[]) => {
(
event: React.DragEvent<HTMLDivElement>,
worktreeId: string,
draggedCopies: DraggedWorktreeCopies
) => {
const draggedIds = draggedCopies.map((copy) => copy.id)
const sourceGroupKey =
ctx.worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null
if (!sourceGroupKey) {
return
}
const reorderDraggedIds = session.getReorderDraggedIds(draggedIds)
const reorderDraggedCopies = session.getReorderDraggedCopies(draggedCopies)
const reorderDraggedIds = uniqueDraggedIds(reorderDraggedCopies)
const reorderUnitDraggedIds = session.getReorderUnitDraggedIds(
sourceGroupKey,
reorderDraggedIds
@@ -60,6 +67,7 @@ export function useWorktreeNativeDrag(args: {
draggingWorktreeId: worktreeId,
sourceGroupKey,
draggedIds,
draggedCopies: reorderDraggedCopies,
reorderDraggedIds,
reorderUnitDraggedIds,
rects,
@@ -1,6 +1,6 @@
import { useCallback, useEffect } from 'react'
import type React from 'react'
import type { WorkspaceStatus, Worktree } from '../../../../../../shared/worktree/types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { hasWorkspaceKanbanSidebarDropBoard } from '../../workspace-kanban-sidebar-drop'
import {
@@ -12,6 +12,7 @@ import { getWorktreeSidebarDragRectsForGroup } from '../../worktree-sidebar-drag
import { getWorktreeSidebarDragGrab } from '../../worktree-sidebar-drag-geometry'
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './drop-commit-context'
import type {
ShouldShowWorkspaceBoardDropIndicator,
WorktreeDropCommitContext,
WorktreeStatusDropAtIndexArgs
} from './drop-commit-context'
@@ -21,6 +22,7 @@ import { useWorktreePointerDragAutoscroll } from './use-pointer-autoscroll'
import { useWorktreePointerDragWindowEvents } from './use-pointer-window-events'
import { flushWorktreePointerDragFrame } from './pointer-flush'
import { EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, type WorktreePointerDrag } from './row-state'
import { draggedWorktreeCopiesOf, uniqueDraggedIds } from './dragged-worktree-copies'
export function useWorktreePointerDrag(args: {
ctx: WorktreeDropCommitContext
@@ -34,10 +36,7 @@ export function useWorktreePointerDrag(args: {
onWorkspaceBoardDragPreviewStart: () => void
onWorkspaceBoardDragPreviewCommit: () => void
onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void
shouldShowWorkspaceBoardDropIndicator: (
worktreeIds: readonly string[],
status: WorkspaceStatus
) => boolean
shouldShowWorkspaceBoardDropIndicator: ShouldShowWorkspaceBoardDropIndicator
}) {
const {
ctx,
@@ -125,6 +124,7 @@ export function useWorktreePointerDrag(args: {
draggingWorktreeId: drag.worktreeId,
sourceGroupKey: drag.sourceGroupKey,
draggedIds: drag.draggedIds,
draggedCopies: drag.draggedCopies,
reorderDraggedIds: drag.reorderDraggedIds,
reorderUnitDraggedIds: drag.reorderUnitDraggedIds,
rects: drag.rects,
@@ -180,11 +180,14 @@ export function useWorktreePointerDrag(args: {
) {
return
}
const draggedIds =
const draggedCopies = draggedWorktreeCopiesOf(
worktree,
selectedWorktrees,
selectedWorktreeIds.has(getWorktreeHostIdentity(worktree)) && selectedWorktrees.length > 1
? selectedWorktrees.map((worktree) => worktree.id)
: [worktreeId]
const reorderDraggedIds = session.getReorderDraggedIds(draggedIds)
)
const draggedIds = draggedCopies.map((copy) => copy.id)
const reorderDraggedCopies = session.getReorderDraggedCopies(draggedCopies)
const reorderDraggedIds = uniqueDraggedIds(reorderDraggedCopies)
const reorderUnitDraggedIds = session.getReorderUnitDraggedIds(
sourceGroupKey,
reorderDraggedIds
@@ -198,6 +201,7 @@ export function useWorktreePointerDrag(args: {
currentY: event.clientY,
worktreeId,
draggedIds,
draggedCopies: reorderDraggedCopies,
reorderDraggedIds,
reorderUnitDraggedIds,
sourceGroupKey,
@@ -25,6 +25,12 @@ import {
import { getWorktreeDragGroups, getWorktreeDragIndexes } from './groups'
import type { WorktreeItemRow } from '../listing/renderable-rows'
import { getNaturalWorktreeIds } from '../../natural-worktree-ids'
import {
composeWorktreeHostIdentity,
getExecutionHostIdFromWorktreeHostIdentity,
getWorktreeIdFromHostIdentity
} from '../../../../../../shared/worktree/host-qualified-identity'
import type { DraggedWorktreeCopies } from './dragged-worktree-copies'
export type WorktreeStatusDropRequest = {
pointerY: number
@@ -58,7 +64,11 @@ export function useWorktreeDragSession(args: {
(row) =>
row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id)
)
.map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })),
.map((row) => ({
worktreeId: row.worktree.id,
hostId: row.worktree.hostId,
depth: row.depth
})),
[naturalDragWorktreeIds, rows]
)
const getReorderDraggedIds = useCallback(
@@ -66,6 +76,21 @@ export function useWorktreeDragSession(args: {
expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds),
[worktreeLineageDragRows]
)
// Why by identity: by id, a dragged twin would also pull in its other-host copy's children.
const getReorderDraggedCopies = useCallback(
(copies: DraggedWorktreeCopies): DraggedWorktreeCopies =>
expandDraggedWorktreeIdsForVisibleLineage(
worktreeLineageDragRows.map((row) => ({
worktreeId: composeWorktreeHostIdentity(row.hostId, row.worktreeId),
depth: row.depth
})),
copies.map((copy) => composeWorktreeHostIdentity(copy.hostId, copy.id))
).map((identity) => ({
id: getWorktreeIdFromHostIdentity(identity),
hostId: getExecutionHostIdFromWorktreeHostIdentity(identity)
})),
[worktreeLineageDragRows]
)
const getReorderUnitDraggedIds = useCallback(
(sourceGroupKey: string, reorderDraggedIds: readonly string[]) => {
const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey)
@@ -217,6 +242,7 @@ export function useWorktreeDragSession(args: {
groupKeyByRowKey,
groupIndexByRowKey,
getReorderDraggedIds,
getReorderDraggedCopies,
getReorderUnitDraggedIds,
refreshWorktreeDragSession,
computeWorktreeDrop,
@@ -225,6 +251,7 @@ export function useWorktreeDragSession(args: {
[
computeWorktreeDrop,
computeWorktreeStatusDrop,
getReorderDraggedCopies,
getReorderDraggedIds,
getReorderUnitDraggedIds,
groupIndexByRowKey,
@@ -2,70 +2,70 @@ import { useCallback } from 'react'
import { useAppStore } from '@/store'
import type {
WorkspaceStatus,
WorkspaceStatusDefinition,
Worktree
WorkspaceStatusDefinition
} from '../../../../../../shared/worktree/types'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
import type { WorktreeMetaBatchUpdate } from '../../../../store/slices/worktree-helpers'
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
import {
buildManualOrderUpdatesForGroupDrop,
buildManualOrderUpdatesForVisibleGroups,
shouldWriteManualOrderForGroupDrop,
type WorktreeDragGroup
shouldWriteManualOrderForGroupDrop
} from '../../worktree-manual-order'
import { buildWorkspaceKanbanSidebarDropUpdates } from '../../workspace-kanban-sidebar-drop'
import type { SortBy } from '../../../../../../shared/sidebar-list/smart-sort'
import { switchSortToManualAfterDrop } from '../../manual-sort-switch-toast'
import type { WorktreeStatusDropAtIndexArgs } from './drop-commit-context'
import type { WorktreeReorderArgs, WorktreeStatusDropAtIndexArgs } from './drop-commit-context'
import type { WorktreeManualOrderCatalog } from '../../worktree-manual-order-catalog'
import {
collectDropWrites,
dropWriteRows,
type DraggedWorktreeCopies
} from './dragged-worktree-copies'
// Every write a sidebar drop can make: status changes, pin, manual order, and the board lane drop.
// Each id is written through `dropWriteRows`, never an id-keyed map: a twin on another host shares it.
export function useWorktreeStatusMutations(args: {
worktreeMap: Map<string, Worktree>
manualOrderCatalog: WorktreeManualOrderCatalog
workspaceStatuses: readonly WorkspaceStatusDefinition[]
sortBy: SortBy
}) {
const { manualOrderCatalog, worktreeMap, workspaceStatuses, sortBy } = args
const { manualOrderCatalog, workspaceStatuses, sortBy } = args
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal)
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
const current = worktreeMap.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
return
}
void updateWorktreeMeta(
worktreeId,
{ workspaceStatus: status },
{ executionHostId: current.hostId ?? 'local' }
)
},
[updateWorktreeMeta, worktreeMap, workspaceStatuses]
)
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates: WorktreeMetaBatchUpdate[] = []
for (const worktreeId of worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
continue
}
updates.push({
worktreeId,
updates: { workspaceStatus: status },
executionHostId: current.hostId ?? 'local'
})
}
(
worktreeIds: readonly string[],
status: WorkspaceStatus,
draggedCopies?: DraggedWorktreeCopies
) => {
const updates = collectDropWrites({
rowsFor: dropWriteRows(useAppStore.getState(), draggedCopies),
statusIds: worktreeIds,
status,
workspaceStatuses,
orderUpdates: new Map()
})
if (updates.length > 0) {
void updateWorktreesMeta(updates)
}
},
[updateWorktreesMeta, worktreeMap, workspaceStatuses]
[updateWorktreesMeta, workspaceStatuses]
)
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
for (const row of dropWriteRows(useAppStore.getState(), undefined).status(worktreeId)) {
if (getWorkspaceStatus(row, workspaceStatuses) !== status) {
void updateWorktreeMeta(
worktreeId,
{ workspaceStatus: status },
{ executionHostId: row.hostId ?? 'local' }
)
}
}
},
[updateWorktreeMeta, workspaceStatuses]
)
const moveWorktreesToStatusAtIndex = useCallback(
@@ -79,43 +79,24 @@ export function useWorktreeStatusMutations(args: {
rankByWorktreeId: manualOrderCatalog.rankByWorktreeId,
allWorktreeIds: manualOrderCatalog.orderedIds
})
const updates = new Map<string, WorktreeMetaBatchUpdate>()
for (const worktreeId of dropArgs.worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current) {
continue
}
const next: Partial<WorktreeMeta> = {}
if (getWorkspaceStatus(current, workspaceStatuses) !== dropArgs.status) {
next.workspaceStatus = dropArgs.status
}
updates.set(worktreeId, {
worktreeId,
updates: next,
executionHostId: current.hostId ?? 'local'
})
}
for (const [worktreeId, manualOrder] of order.updates) {
const entry = updates.get(worktreeId)
if (entry) {
entry.updates = { ...entry.updates, ...manualOrder }
}
}
for (const [worktreeId, entry] of updates) {
if (Object.keys(entry.updates).length === 0) {
updates.delete(worktreeId)
}
}
if (updates.size === 0) {
const dragged = new Set(dropArgs.worktreeIds)
const updates = collectDropWrites({
rowsFor: dropWriteRows(useAppStore.getState(), dropArgs.draggedCopies),
statusIds: dropArgs.worktreeIds,
status: dropArgs.status,
workspaceStatuses,
orderUpdates: new Map([...order.updates].filter(([worktreeId]) => dragged.has(worktreeId)))
})
if (updates.length === 0) {
return
}
// Why: the insertion line promises exact placement, so persist manual order on a cross-status drop.
if (order.changed) {
switchSortToManualAfterDrop()
}
void updateWorktreesMeta([...updates.values()])
void updateWorktreesMeta(updates)
},
[manualOrderCatalog, updateWorktreesMeta, worktreeMap, workspaceStatuses]
[manualOrderCatalog, updateWorktreesMeta, workspaceStatuses]
)
const pinWorktree = useCallback(
@@ -133,52 +114,54 @@ export function useWorktreeStatusMutations(args: {
)
const reorderWorktrees = useCallback(
(reorderArgs: {
groups: readonly WorktreeDragGroup[]
sourceGroupKey: string
draggedIds: readonly string[]
dropIndex: number
}) => {
(reorderArgs: WorktreeReorderArgs) => {
const result = buildManualOrderUpdatesForVisibleGroups({
...reorderArgs,
now: Date.now(),
rankByWorktreeId: manualOrderCatalog.rankByWorktreeId,
allWorktreeIds: manualOrderCatalog.orderedIds
})
const updates = collectDropWrites({
rowsFor: dropWriteRows(useAppStore.getState(), reorderArgs.draggedCopies),
statusIds: [],
status: null,
workspaceStatuses,
orderUpdates: result.updates
})
if (updates.length === 0) {
return
}
if (result.changed) {
switchSortToManualAfterDrop()
}
void updateWorktreesMeta(
[...result.updates].map(([worktreeId, updates]) => ({
worktreeId,
updates,
executionHostId: worktreeMap.get(worktreeId)?.hostId ?? 'local'
}))
)
void updateWorktreesMeta(updates)
},
[manualOrderCatalog, updateWorktreesMeta, worktreeMap]
[manualOrderCatalog, updateWorktreesMeta, workspaceStatuses]
)
const shouldShowWorkspaceBoardDropIndicator = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => {
const worktree = worktreeMap.get(worktreeId)
return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : []
})
(
worktreeIds: readonly string[],
status: WorkspaceStatus,
draggedCopies?: DraggedWorktreeCopies
) => {
const rowsFor = dropWriteRows(useAppStore.getState(), draggedCopies)
return shouldWriteManualOrderForGroupDrop({
sortBy,
sourceGroupKeys,
sourceGroupKeys: worktreeIds.flatMap((worktreeId) =>
rowsFor.status(worktreeId).map((row) => getWorkspaceStatus(row, workspaceStatuses))
),
targetGroupKey: status
})
},
[sortBy, worktreeMap, workspaceStatuses]
[sortBy, workspaceStatuses]
)
const dropWorktreesOnWorkspaceBoard = useCallback(
(dropArgs: WorktreeStatusDropAtIndexArgs) => {
const result = buildWorkspaceKanbanSidebarDropUpdates({
...dropArgs,
worktreeById: worktreeMap,
rowsFor: dropWriteRows(useAppStore.getState(), dropArgs.draggedCopies),
workspaceStatuses,
sortBy,
now: Date.now(),
@@ -195,7 +178,7 @@ export function useWorktreeStatusMutations(args: {
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
void updateWorktreesMeta(result.updates)
},
[manualOrderCatalog, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses]
[manualOrderCatalog, sortBy, updateWorktreesMeta, workspaceStatuses]
)
return {
@@ -1,3 +1,4 @@
import type { DraggedWorktreeCopies } from '../drag/dragged-worktree-copies'
import React from 'react'
import { cn } from '@/lib/utils'
import type { AppState } from '@/store/types'
@@ -56,7 +57,7 @@ export type WorktreeItemRowContext = {
onCardDragStart: (
event: React.DragEvent<HTMLDivElement>,
worktreeId: string,
draggedIds: readonly string[]
draggedCopies: DraggedWorktreeCopies
) => void
onCardDragEnd: () => void
}
@@ -20,8 +20,12 @@ import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
import type { ImportedWorktreeCardActionState } from '../../imported-worktrees-card-actions'
import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions'
import type { WorktreeDragGroup } from '../../worktree-manual-order'
import type { WorktreeStatusDropAtIndexArgs } from '../drag/drop-commit-context'
import type {
ShouldShowWorkspaceBoardDropIndicator,
WorktreeDropCommitContext,
WorktreeReorderArgs,
WorktreeStatusDropAtIndexArgs
} from '../drag/drop-commit-context'
import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping'
import type {
PinnedWorktreeDisplayPolicy,
@@ -88,7 +92,7 @@ export type VirtualizedWorktreeViewportProps = {
/** 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
onMoveWorktreesToStatus: WorktreeDropCommitContext['onMoveWorktreesToStatus']
onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void
onPinWorktree: (worktreeId: string) => void
onPinWorktrees: (worktreeIds: readonly string[]) => void
@@ -98,16 +102,8 @@ export type VirtualizedWorktreeViewportProps = {
onWorkspaceBoardDragPreviewStart: () => void
onWorkspaceBoardDragPreviewCommit: () => void
onWorkspaceBoardDragPreviewCancel: () => void
shouldShowWorkspaceBoardDropIndicator: (
worktreeIds: readonly string[],
status: WorkspaceStatus
) => boolean
onReorderWorktrees: (args: {
groups: readonly WorktreeDragGroup[]
sourceGroupKey: string
draggedIds: readonly string[]
dropIndex: number
}) => void
shouldShowWorkspaceBoardDropIndicator: ShouldShowWorkspaceBoardDropIndicator
onReorderWorktrees: (args: WorktreeReorderArgs) => void
// Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0.
scrollOffsetRef: React.MutableRefObject<number>
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
@@ -42,7 +42,6 @@ function seedManualOrderedRows(count: number): Worktree[] {
function renderReorder(worktrees: readonly Worktree[]) {
return renderHook(() =>
useWorktreeStatusMutations({
worktreeMap: new Map(worktrees.map((worktree) => [worktree.id, worktree])),
manualOrderCatalog: buildWorktreeManualOrderCatalog({
worktrees,
folderWorkspaces: []
@@ -19,6 +19,7 @@ const SESSION: WorktreeSidebarDragSession = {
draggingWorktreeId: 'b',
sourceGroupKey: 'repo:one',
draggedIds: ['b'],
draggedCopies: [],
reorderDraggedIds: ['b'],
reorderUnitDraggedIds: ['b'],
rects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }],
@@ -1,3 +1,4 @@
import type { DraggedWorktreeCopies } from './worktree-list/drag/dragged-worktree-copies'
import type { WorktreeDragGroup } from './worktree-manual-order'
import type { WorktreeDragUnitGroup } from './worktree-drag-units'
import type {
@@ -27,6 +28,7 @@ export type WorktreeSidebarDragSession = {
draggingWorktreeId: string
sourceGroupKey: string
draggedIds: readonly string[]
draggedCopies: DraggedWorktreeCopies
reorderDraggedIds: readonly string[]
reorderUnitDraggedIds: readonly string[]
// Why: one live coordinate space for both hit testing and rendering. Stability
@@ -182,6 +182,7 @@ describe('worktree sidebar drag geometry under mid-drag card growth', () => {
draggingWorktreeId: 'a',
sourceGroupKey: 'repo:one',
draggedIds: ['a'],
draggedCopies: [],
reorderDraggedIds: ['a'],
reorderUnitDraggedIds: ['a'],
rects: COLLAPSED,