fix(workspace-board): sync Linear on context-menu Move to Status (#10176)
* fix(workspace-board): sync Linear on context-menu Move to Status The board's right-click "Move to Status" only wrote the local workspaceStatus and silently dropped the Linear sync that drag-and-drop performs. Thread an onAssignWorkspaceStatus callback from the drawer through the kanban card chain into WorktreeContextMenu so the menu funnels through the same local-first + Linear-sync path (moveWorktreesToStatus) as drag-and-drop. Outside the board (sidebar list) the menu keeps its local-only behavior. * test(workspace-board): guard context-menu Move to Status routing Extract the context-menu status-assign routing into a pure planWorkspaceStatusAssignment helper (behavior-preserving) and unit-test it, so the board Linear-sync vs sidebar local-only branch — the exact path #10175 regressed on — cannot silently flip back unnoticed. Covers board-sync-forwards-all-ids, local-only-writes-only-changed, and the same-status no-op case. Addresses code-review finding: the added drawer tests exercised the sync wiring via a mocked LaneGrid but never the menu's routing branch. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: ElNelyo <ElNelyo@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
1b01385872
commit
0bb755151c
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import { Pin } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types'
|
||||
import WorktreeCard from './WorktreeCard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ type WorkspaceKanbanCardProps = {
|
|||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
}
|
||||
|
||||
function WorkspaceKanbanCard({
|
||||
|
|
@ -29,7 +30,8 @@ function WorkspaceKanbanCard({
|
|||
nativeDragEnabled = true,
|
||||
onActivate,
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect
|
||||
onContextMenuSelect,
|
||||
onAssignWorkspaceStatus
|
||||
}: WorkspaceKanbanCardProps): React.JSX.Element {
|
||||
const contextWorktrees =
|
||||
isSelected && selectedWorktrees && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
|
||||
|
|
@ -61,6 +63,7 @@ function WorkspaceKanbanCard({
|
|||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ const {
|
|||
toastErrorMock,
|
||||
toastWarningMock,
|
||||
pointerDragState,
|
||||
documentDropState
|
||||
documentDropState,
|
||||
laneAssignStatusState
|
||||
} = vi.hoisted(() => ({
|
||||
syncWorkspaceBoardTaskStatusesMock: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
|
|
@ -44,7 +45,10 @@ const {
|
|||
toastErrorMock: vi.fn(),
|
||||
toastWarningMock: vi.fn(),
|
||||
pointerDragState: { current: null as PointerDragParams | null },
|
||||
documentDropState: { current: null as DocumentDropCapture | null }
|
||||
documentDropState: { current: null as DocumentDropCapture | null },
|
||||
laneAssignStatusState: {
|
||||
current: null as ((worktreeIds: readonly string[], status: string) => void) | null
|
||||
}
|
||||
}))
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
|
@ -81,7 +85,14 @@ vi.mock('./WorkspaceKanbanDrawerHeader', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanLaneGrid', () => ({
|
||||
default: () => <div data-testid="workspace-board-lanes" />
|
||||
default: ({
|
||||
onAssignWorkspaceStatus
|
||||
}: {
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: string) => void
|
||||
}) => {
|
||||
laneAssignStatusState.current = onAssignWorkspaceStatus ?? null
|
||||
return <div data-testid="workspace-board-lanes" />
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({
|
||||
|
|
@ -255,6 +266,7 @@ beforeEach(() => {
|
|||
root = createRoot(container)
|
||||
pointerDragState.current = null
|
||||
documentDropState.current = null
|
||||
laneAssignStatusState.current = null
|
||||
syncWorkspaceBoardTaskStatusesMock.mockClear()
|
||||
toastErrorMock.mockClear()
|
||||
toastWarningMock.mockClear()
|
||||
|
|
@ -400,6 +412,44 @@ describe('WorkspaceKanbanDrawer task status sync wiring', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('syncs Linear when the board context-menu "Move to Status" assigns a status', () => {
|
||||
const item = worktree()
|
||||
renderDrawer(item)
|
||||
|
||||
act(() => {
|
||||
laneAssignStatusState.current?.([item.id], 'in-review')
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeIds: [item.id],
|
||||
targetStatus: { id: 'in-review', label: 'In review' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not sync a context-menu status move when the setting is disabled', () => {
|
||||
const item = worktree()
|
||||
renderDrawer(item, false)
|
||||
|
||||
act(() => {
|
||||
laneAssignStatusState.current?.([item.id], 'in-review')
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not sync a context-menu status move that keeps the same board status', () => {
|
||||
const item = worktree({ workspaceStatus: 'in-review' })
|
||||
renderDrawer(item)
|
||||
|
||||
act(() => {
|
||||
laneAssignStatusState.current?.([item.id], 'in-review')
|
||||
})
|
||||
|
||||
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when task status sync unexpectedly rejects', async () => {
|
||||
syncWorkspaceBoardTaskStatusesMock.mockRejectedValueOnce(new Error('Runtime disconnected'))
|
||||
const item = worktree()
|
||||
|
|
|
|||
|
|
@ -288,6 +288,30 @@ export default function WorkspaceKanbanDrawer({
|
|||
},
|
||||
[maybeSyncWorkspaceBoardTaskStatuses, updateWorktreeMeta, workspaceStatuses, worktreeById]
|
||||
)
|
||||
// Why: the board's context-menu "Move to Status" must funnel through the same
|
||||
// local-first + Linear-sync path as drag-and-drop. Without this callback the
|
||||
// menu only writes the local status and silently drops the Linear sync.
|
||||
const moveWorktreesToStatus = useCallback(
|
||||
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
|
||||
const updates = new Map<string, Partial<WorktreeMeta>>()
|
||||
const changedIds: string[] = []
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const current = worktreeById.get(worktreeId)
|
||||
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
|
||||
continue
|
||||
}
|
||||
changedIds.push(worktreeId)
|
||||
updates.set(worktreeId, { workspaceStatus: status })
|
||||
}
|
||||
if (changedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
|
||||
void updateWorktreesMeta(updates)
|
||||
maybeSyncWorkspaceBoardTaskStatuses(changedIds, status)
|
||||
},
|
||||
[maybeSyncWorkspaceBoardTaskStatuses, updateWorktreesMeta, workspaceStatuses, worktreeById]
|
||||
)
|
||||
const getSourceStatusKeys = useCallback(
|
||||
(worktreeIds: readonly string[]): WorkspaceStatus[] =>
|
||||
worktreeIds.flatMap((worktreeId) => {
|
||||
|
|
@ -775,6 +799,7 @@ export default function WorkspaceKanbanDrawer({
|
|||
onActivate={handleWorktreeActivate}
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
onContextMenuSelect={selectForContextMenu}
|
||||
onAssignWorkspaceStatus={moveWorktreesToStatus}
|
||||
onCreateWorktree={createWorktreeForStatus}
|
||||
onColumnResizeStart={onColumnResizeStart}
|
||||
onColumnResizeKeyDown={onColumnResizeKeyDown}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type WorkspaceKanbanLaneGridProps = {
|
|||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
onCreateWorktree: (statusId: string) => void
|
||||
onColumnResizeStart: (event: React.PointerEvent<HTMLElement>) => void
|
||||
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
|
||||
|
|
@ -49,6 +50,7 @@ export default function WorkspaceKanbanLaneGrid({
|
|||
onActivate,
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect,
|
||||
onAssignWorkspaceStatus,
|
||||
onCreateWorktree,
|
||||
onColumnResizeStart,
|
||||
onColumnResizeKeyDown
|
||||
|
|
@ -81,6 +83,7 @@ export default function WorkspaceKanbanLaneGrid({
|
|||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
onCreateWorktree={onCreateWorktree}
|
||||
onColumnResizeStart={onColumnResizeStart}
|
||||
onColumnResizeKeyDown={onColumnResizeKeyDown}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types'
|
||||
import type {
|
||||
Repo,
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
WORKSPACE_BOARD_COLUMN_WIDTH_MAX,
|
||||
WORKSPACE_BOARD_COLUMN_WIDTH_MIN
|
||||
|
|
@ -33,6 +38,7 @@ type WorkspaceKanbanStatusLaneProps = {
|
|||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
onCreateWorktree: (statusId: string) => void
|
||||
onColumnResizeStart: (event: React.PointerEvent<HTMLElement>) => void
|
||||
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
|
||||
|
|
@ -56,6 +62,7 @@ export default function WorkspaceKanbanStatusLane({
|
|||
onActivate,
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect,
|
||||
onAssignWorkspaceStatus,
|
||||
onCreateWorktree,
|
||||
onColumnResizeStart,
|
||||
onColumnResizeKeyDown
|
||||
|
|
@ -166,6 +173,7 @@ export default function WorkspaceKanbanStatusLane({
|
|||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-revi
|
|||
import type {
|
||||
GitHubWorkItem,
|
||||
Worktree,
|
||||
WorkspaceStatus,
|
||||
Repo,
|
||||
IssueInfo,
|
||||
LinearIssue
|
||||
|
|
@ -133,6 +134,7 @@ type WorktreeCardProps = {
|
|||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
onCardDragStart?: (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
worktreeId: string,
|
||||
|
|
@ -216,6 +218,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
onImmediateActivate,
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect,
|
||||
onAssignWorkspaceStatus,
|
||||
onCardDragStart,
|
||||
onCardDragEnd,
|
||||
nativeDragEnabled = true,
|
||||
|
|
@ -1905,6 +1908,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
worktree={worktree}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onContextMenuSelect={handleContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
>
|
||||
{cardBody}
|
||||
</WorktreeContextMenu>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import {
|
|||
getWorktreeParentPickerLabel,
|
||||
hasWorktreeParentLink,
|
||||
isWorktreeParentPickerDisabled,
|
||||
planWorkspaceStatusAssignment,
|
||||
selectMenuScopedMap
|
||||
} from './WorktreeContextMenu'
|
||||
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import type { Worktree, WorktreeLineage, WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
|
||||
describe('selectMenuScopedMap (delete-teardown re-render guard)', () => {
|
||||
// Why: the closed menu wrapper must stay inert to delete teardown's high-churn
|
||||
|
|
@ -234,3 +235,49 @@ describe('project removal from workspace context menus', () => {
|
|||
expect(isContextWorktreeDeletable({ isMainWorktree: false }, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planWorkspaceStatusAssignment (context-menu "Move to Status" routing)', () => {
|
||||
// Why: this is the exact branch #10175 regressed on — the board must funnel
|
||||
// through the Linear-sync callback, the sidebar list must stay local-only. A
|
||||
// silent flip of either branch re-introduces the bug, so pin both here.
|
||||
const statuses: WorkspaceStatusDefinition[] = [
|
||||
{ id: 'todo', label: 'Todo' },
|
||||
{ id: 'in-review', label: 'In review' }
|
||||
]
|
||||
const wt = (id: string, workspaceStatus: string): Worktree =>
|
||||
({ id, workspaceStatus }) as Worktree
|
||||
|
||||
it('routes to board Linear-sync with ALL selected ids when the board wired a callback', () => {
|
||||
// The board path forwards every id; moveWorktreesToStatus filters no-ops downstream.
|
||||
expect(
|
||||
planWorkspaceStatusAssignment(
|
||||
[wt('a', 'todo'), wt('b', 'in-review')],
|
||||
'in-review',
|
||||
statuses,
|
||||
true
|
||||
)
|
||||
).toEqual({ kind: 'board-sync', worktreeIds: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('falls back to local-only writes of only status-changed worktrees off the board', () => {
|
||||
expect(
|
||||
planWorkspaceStatusAssignment(
|
||||
[wt('a', 'todo'), wt('b', 'in-review')],
|
||||
'in-review',
|
||||
statuses,
|
||||
false
|
||||
)
|
||||
).toEqual({ kind: 'local-only', localWriteIds: ['a'] })
|
||||
})
|
||||
|
||||
it('writes nothing on the local-only path when every worktree already has the target status', () => {
|
||||
expect(
|
||||
planWorkspaceStatusAssignment(
|
||||
[wt('a', 'in-review'), wt('b', 'in-review')],
|
||||
'in-review',
|
||||
statuses,
|
||||
false
|
||||
)
|
||||
).toEqual({ kind: 'local-only', localWriteIds: [] })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,7 +35,12 @@ import { useAppStore } from '@/store'
|
|||
import type { AppState } from '@/store/types'
|
||||
import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type {
|
||||
Repo,
|
||||
Worktree,
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition
|
||||
} from '../../../../shared/types'
|
||||
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
|
||||
import { runSleepWorktrees } from './sleep-worktree-flow'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
|
|
@ -65,6 +70,7 @@ type Props = {
|
|||
contentClassName?: string
|
||||
selectedWorktrees?: readonly Worktree[]
|
||||
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => readonly Worktree[]
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
|
|
@ -278,12 +284,36 @@ function preserveDeleteSiblingPosition(scope: HTMLElement | null): () => void {
|
|||
}
|
||||
}
|
||||
|
||||
export type WorkspaceStatusAssignmentPlan =
|
||||
| { readonly kind: 'board-sync'; readonly worktreeIds: readonly string[] }
|
||||
| { readonly kind: 'local-only'; readonly localWriteIds: readonly string[] }
|
||||
|
||||
// Why: the context-menu "Move to Status" routes to the board's local-first +
|
||||
// Linear-sync path when the board wired a callback, else a local-only write of
|
||||
// only the status-changed worktrees. Extracted pure so the routing and the
|
||||
// no-op filter stay unit-testable without opening the Radix menu.
|
||||
export function planWorkspaceStatusAssignment(
|
||||
worktrees: readonly Worktree[],
|
||||
status: WorkspaceStatus,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[],
|
||||
boardSyncEnabled: boolean
|
||||
): WorkspaceStatusAssignmentPlan {
|
||||
if (boardSyncEnabled) {
|
||||
return { kind: 'board-sync', worktreeIds: worktrees.map((item) => item.id) }
|
||||
}
|
||||
const localWriteIds = worktrees
|
||||
.filter((item) => getWorkspaceStatus(item, workspaceStatuses) !== status)
|
||||
.map((item) => item.id)
|
||||
return { kind: 'local-only', localWriteIds }
|
||||
}
|
||||
|
||||
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
worktree,
|
||||
children,
|
||||
contentClassName,
|
||||
selectedWorktrees,
|
||||
onContextMenuSelect,
|
||||
onAssignWorkspaceStatus,
|
||||
onOpenChange
|
||||
}: Props) {
|
||||
const defaultSelectedWorktrees = useMemo(() => [worktree], [worktree])
|
||||
|
|
@ -504,15 +534,29 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
const handleAssignWorkspaceStatus = useCallback(
|
||||
(status: string) => {
|
||||
setMenuOpenState(false)
|
||||
const plan = planWorkspaceStatusAssignment(
|
||||
activeContextWorktrees,
|
||||
status,
|
||||
workspaceStatuses,
|
||||
Boolean(onAssignWorkspaceStatus)
|
||||
)
|
||||
if (plan.kind === 'board-sync') {
|
||||
onAssignWorkspaceStatus?.(plan.worktreeIds, status)
|
||||
return
|
||||
}
|
||||
// Why: outside the workspace board (e.g. the sidebar list) status changes
|
||||
// are local-only; Linear sync is scoped to board moves like drag-and-drop.
|
||||
void Promise.all(
|
||||
activeContextWorktrees.map((item) =>
|
||||
getWorkspaceStatus(item, workspaceStatuses) === status
|
||||
? Promise.resolve()
|
||||
: updateWorktreeMeta(item.id, { workspaceStatus: status })
|
||||
)
|
||||
plan.localWriteIds.map((id) => updateWorktreeMeta(id, { workspaceStatus: status }))
|
||||
)
|
||||
},
|
||||
[activeContextWorktrees, setMenuOpenState, updateWorktreeMeta, workspaceStatuses]
|
||||
[
|
||||
activeContextWorktrees,
|
||||
onAssignWorkspaceStatus,
|
||||
setMenuOpenState,
|
||||
updateWorktreeMeta,
|
||||
workspaceStatuses
|
||||
]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue