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