From 1529b0f73b7a5a8878d924a79a37cd6bebf126e5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:47:02 -0700 Subject: [PATCH] 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. --- .../src/components/sidebar/WorktreeList.tsx | 1 - .../use-worktree-card-workspace-actions.ts | 19 +- .../workspace-kanban-sidebar-drop.test.ts | 14 +- .../sidebar/workspace-kanban-sidebar-drop.ts | 59 +-- .../components/sidebar/worktree-card-model.ts | 3 +- .../drag/dragged-worktree-copies.ts | 98 +++++ .../worktree-list/drag/drop-commit-context.ts | 29 +- .../worktree-list/drag/pointer-commit.ts | 1 + .../worktree-list/drag/pointer-flush.test.ts | 1 + .../worktree-list/drag/pointer-flush.ts | 16 +- .../sidebar/worktree-list/drag/row-state.ts | 2 + .../drag/status-drop-twin-host.test.ts | 363 ++++++++++++++++++ .../drag/use-drop-commit-context.ts | 57 +-- .../worktree-list/drag/use-native-drag.ts | 12 +- .../worktree-list/drag/use-pointer-drag.ts | 22 +- .../sidebar/worktree-list/drag/use-session.ts | 29 +- .../drag/use-status-mutations.ts | 165 ++++---- .../sidebar/worktree-list/rows/item-row.tsx | 3 +- .../worktree-list/viewport/viewport-props.ts | 22 +- .../worktree-manual-order-store-write.test.ts | 1 - .../worktree-sidebar-drag-autoscroll.test.ts | 1 + .../worktree-sidebar-drag-autoscroll.ts | 2 + .../worktree-sidebar-drag-geometry.test.ts | 1 + 23 files changed, 709 insertions(+), 212 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 1753db58873..fcd3cfee1ad 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -145,7 +145,6 @@ const WorktreeList = React.memo(function WorktreeList({ }) const statusMutations = useWorktreeStatusMutations({ manualOrderCatalog, - worktreeMap, workspaceStatuses, sortBy }) diff --git a/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts b/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts index 7b1349497d3..4a40e46e577 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 4edd77c6357..6c4a58bab66 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -550,6 +550,14 @@ describe('workspace kanban sidebar drop DOM bridge', () => { }) }) +function rowsFrom(byId: ReadonlyMap) { + 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, diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index d0db89c9faa..f0572f5e9d9 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -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 + /** The rows each id may write; a twin's id names another host's copy too. */ + rowsFor: DropWriteRows allWorktreeIds: readonly string[] rankByWorktreeId: ReadonlyMap 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() } - const updates: WorktreeMetaBatchUpdate[] = [] - for (const worktreeId of args.worktreeIds) { - const current = args.worktreeById.get(worktreeId) - if (!current) { - continue - } - const next: Partial = {} - 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 } } diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts index c13c3b17fa8..d4f32129dd1 100644 --- a/src/renderer/src/components/sidebar/worktree-card-model.ts +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -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, worktreeId: string, - draggedIds: readonly string[] + draggedCopies: DraggedWorktreeCopies ) => void onCardDragEnd?: (event: React.DragEvent) => void nativeDragEnabled?: boolean diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts b/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts new file mode 100644 index 00000000000..84c14207278 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts @@ -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[] + +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, + copies: DraggedWorktreeCopies | undefined +): DropWriteRows { + const rowsById = new Map() + // 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 +}): WorktreeMetaBatchUpdate[] { + const writes = new Map() + const write = (row: Worktree, updates: Partial): 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()] +} diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts index beb943d57ea..f245ccf3d7c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts @@ -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 } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts index 01e3a455d41..d8b53a432cd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts index 437891d6f69..0029e39b06c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts @@ -42,6 +42,7 @@ function setup() { currentY: 300, worktreeId: 'child', draggedIds: ['child'], + draggedCopies: [], reorderDraggedIds: ['child'], reorderUnitDraggedIds: ['child'], sourceGroupKey: 'repo', diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts index 0c9017fc0f5..20e0585296f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.ts @@ -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> 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 = { diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts index 1fd257df808..008618edc07 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/row-state.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts new file mode 100644 index 00000000000..04c5b87d70f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts @@ -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 + 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) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts index 53552b50ab4..e70eee8ccd0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts @@ -24,8 +24,13 @@ export function useWorktreeDropCommitContext(args: { onReorderWorktrees, onPinWorktrees } = args - return useMemo( - () => ({ + const { worktreePointerDragRef } = runtime + const { worktreeDragSessionRef } = session + return useMemo(() => { + // 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 + ]) } diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts index e83ff97334b..251c92a821f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-native-drag.ts @@ -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, worktreeId: string, draggedIds: readonly string[]) => { + ( + event: React.DragEvent, + 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, diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts index 1b751a18e9b..9b1d26c3c55 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts index fd472d08517..2a087626125 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts index c42ceb18bef..2a20885ec5a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts @@ -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 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() - for (const worktreeId of dropArgs.worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current) { - continue - } - const next: Partial = {} - 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 { diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index 9222fa37ca7..3a9046a9e57 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -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, worktreeId: string, - draggedIds: readonly string[] + draggedCopies: DraggedWorktreeCopies ) => void onCardDragEnd: () => void } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts index 4629e4658df..26d01704d00 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts @@ -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 scrollAnchorRef: React.MutableRefObject diff --git a/src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts index 97ff803fd0f..862d8ebb9f3 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order-store-write.test.ts @@ -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: [] diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index bf0bd66b95d..76e5be17c7a 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -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 }], diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 83bfbb29209..05dae1afc86 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts index 6657877a098..164ef4ebe2f 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts @@ -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,