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,