From c8567eb16e35b43db5c15dbd5260debf9c1dd654 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:46:42 -0700 Subject: [PATCH] fix(sidebar): preserve hidden rows in manual order (#16488) --- .../ipc/worktree-git-common-watch.test.ts | 6 +- .../sidebar/WorkspaceKanbanDrawer.tsx | 7 + .../src/components/sidebar/WorktreeList.tsx | 12 +- .../use-workspace-kanban-worktree-actions.ts | 15 +- .../workspace-kanban-sidebar-drop.test.ts | 66 ++++- .../sidebar/workspace-kanban-sidebar-drop.ts | 19 +- .../drag/use-status-mutations.ts | 36 +-- .../worktree-manual-order-catalog.test.ts | 62 +++++ .../sidebar/worktree-manual-order-catalog.ts | 49 ++++ .../sidebar/worktree-manual-order-ranks.ts | 56 ++++- .../sidebar/worktree-manual-order.test.ts | 93 +++++++ .../sidebar/worktree-manual-order.ts | 4 + ...r-workspace-owner-routed-mutations.test.ts | 23 ++ .../worktrees-metadata-persistence.test.ts | 34 +++ .../listing/worktree-owner-settings.ts | 13 +- .../metadata/update-worktrees-meta.ts | 72 +++++- .../manual-worktree-order-persistence.spec.ts | 231 ++++++++++++++++++ 17 files changed, 727 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-manual-order-catalog.ts create mode 100644 tests/e2e/manual-worktree-order-persistence.spec.ts diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 650697b2008..8a3550a8678 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -564,8 +564,8 @@ describe('worktree git-common narrow watch (local native platforms)', () => { await mkdir(worktreesDir) await vi.waitFor(() => { expect(subscribeMock).toHaveBeenCalledTimes(2) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) }) - expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) }) it('drops both visibility subscriptions on dispose', async () => { @@ -637,7 +637,9 @@ describe('worktree git-common narrow watch (local native platforms)', () => { await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - expect(subscribeMock).toHaveBeenCalledTimes(3) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(3) + }) expect(staleSubscription.unsubscribe).toHaveBeenCalledOnce() const beforeStaleEvent = received.length diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 909df554975..a8ada9cc612 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -20,6 +20,7 @@ import { useWorkspaceKanbanBoardProjection } from './use-workspace-kanban-board- import { useWorkspaceKanbanNativeDrag } from './use-workspace-kanban-native-drag' import { useWorkspaceKanbanRenderLifecycle } from './use-workspace-kanban-render-lifecycle' import { useWorkspaceKanbanDrawerLingering } from './use-workspace-kanban-drawer-lingering' +import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' type WorkspaceKanbanDrawerProps = { leftSidebarStyle?: React.CSSProperties @@ -52,6 +53,7 @@ function WorkspaceKanbanDrawerContent({ onMenuOpenChange }: WorkspaceKanbanDrawerProps): React.JSX.Element { const allWorktrees = useAllWorktrees() + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const repoMap = useRepoMap() const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) @@ -73,6 +75,10 @@ function WorkspaceKanbanDrawerContent({ const laneScrollerRef = useRef(null) const areaSelectionOverlayRef = useRef(null) const { createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree() + const manualOrderCatalog = useMemo( + () => buildWorktreeManualOrderCatalog({ worktrees: allWorktrees, folderWorkspaces }), + [allWorktrees, folderWorkspaces] + ) const { activeWorktreeIdentity, boardDragGroups, @@ -135,6 +141,7 @@ function WorkspaceKanbanDrawerContent({ updateWorktreesMeta, workspaceStatuses, worktreeById, + manualOrderCatalog, worktreesByStatus }) // Why: dragging or right-clicking one visible match must not silently move diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 0d81932fb74..5d27ec0f23f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -36,6 +36,7 @@ import { useSidebarWorktreeSortOrder } from './worktree-list/listing/use-sort-or import { useVisibleSidebarWorktrees } from './worktree-list/listing/use-visible-worktrees' import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations' import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate' +import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject @@ -105,6 +106,10 @@ const WorktreeList = React.memo(function WorktreeList({ const agentSendTargetWorktreeId = useAgentSendTargetWorktreeId() const { filterState, hasFilters, clearFilters } = useSidebarWorktreeFilters() const sortedIds = useSidebarWorktreeSortOrder({ allWorktrees, repoMap, sortBy }) + const manualOrderCatalog = useMemo( + () => buildWorktreeManualOrderCatalog({ worktrees: allWorktrees, folderWorkspaces }), + [allWorktrees, folderWorkspaces] + ) const { visibleWorktrees, pairedDeviceIdsByEnvironment } = useVisibleSidebarWorktrees({ filterState, sortBy, @@ -173,7 +178,12 @@ const WorktreeList = React.memo(function WorktreeList({ sectionRows: rowModel.sectionRows, pinnedDisplayPolicy }) - const statusMutations = useWorktreeStatusMutations({ worktreeMap, workspaceStatuses, sortBy }) + const statusMutations = useWorktreeStatusMutations({ + manualOrderCatalog, + worktreeMap, + workspaceStatuses, + sortBy + }) const projectGroupDialogs = useProjectGroupDialogs({ repos, repoMap, projectGroups }) const handleImmediateWorktreeActivate = useCallback((worktreeId: string, rowKey?: string) => { diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts index fd7dcdfcbac..687328e30c6 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-worktree-actions.ts @@ -9,6 +9,7 @@ import { } from './worktree-manual-order' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { WorkspaceStatus, Worktree } from '../../../../shared/worktree/types' +import type { WorktreeManualOrderCatalog } from './worktree-manual-order-catalog' type LaneView = { items: readonly Worktree[] } @@ -23,6 +24,7 @@ export function useWorkspaceKanbanWorktreeActions(args: { updateWorktreesMeta: ReturnType['updateWorktreesMeta'] workspaceStatuses: ReturnType['workspaceStatuses'] worktreeById: ReadonlyMap + manualOrderCatalog: WorktreeManualOrderCatalog worktreesByStatus: ReadonlyMap }) { const recordInteraction = (): void => { @@ -88,16 +90,6 @@ export function useWorkspaceKanbanWorktreeActions(args: { const updates = new Map>() const writeManualOrder = drop.writeManualOrder ?? shouldWriteDropManualOrder(drop.worktreeIds, drop.status) - const rankByWorktreeId = writeManualOrder - ? new Map( - args.boardDragGroups.flatMap((group) => - group.worktreeIds.flatMap((worktreeId) => { - const worktree = args.worktreeById.get(worktreeId) - return worktree ? [[worktreeId, worktree.manualOrder ?? worktree.sortOrder]] : [] - }) - ) - ) - : undefined const order = writeManualOrder ? buildManualOrderUpdatesForGroupDrop({ groups: args.boardDragGroups, @@ -105,7 +97,8 @@ export function useWorkspaceKanbanWorktreeActions(args: { draggedIds: drop.worktreeIds, dropIndex: drop.dropIndex, now: Date.now(), - rankByWorktreeId + rankByWorktreeId: args.manualOrderCatalog.rankByWorktreeId, + allWorktreeIds: args.manualOrderCatalog.orderedIds }) : { changed: false, updates: new Map() } for (const worktreeId of drop.worktreeIds) { 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 bdd9928ff17..0a333ca9180 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 @@ -566,6 +566,8 @@ describe('workspace kanban sidebar drop updates', () => { { key: 'doing', worktreeIds: ['doing-a'] } ], worktreeById, + allWorktreeIds: [...worktreeById.keys()], + rankByWorktreeId: new Map(), workspaceStatuses, sortBy: 'recent', now: 10_000 @@ -591,6 +593,8 @@ describe('workspace kanban sidebar drop updates', () => { { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } ], worktreeById, + allWorktreeIds: [...worktreeById.keys()], + rankByWorktreeId: new Map(), workspaceStatuses, sortBy: 'manual', now: 10_000 @@ -599,7 +603,67 @@ describe('workspace kanban sidebar drop updates', () => { expect(result.shouldSwitchToManual).toBe(true) expect(result.updates.get('todo-a')).toEqual({ workspaceStatus: 'doing', - manualOrder: 1500 + manualOrder: 9000 }) + expect(result.updates.get('doing-a')).toEqual({ manualOrder: 10_000 }) + expect(result.updates.get('doing-b')).toEqual({ manualOrder: 8000 }) + }) + + it('keeps board drops sparse when filtered rows already have durable ranks', () => { + const worktreeById = new Map([ + [ + 'todo-a', + worktree({ + id: 'todo-a', + workspaceStatus: 'todo', + sortOrder: 4000, + manualOrder: 4000 + }) + ], + [ + 'doing-a', + worktree({ + id: 'doing-a', + workspaceStatus: 'doing', + sortOrder: 2000, + manualOrder: 2000 + }) + ], + [ + 'doing-b', + worktree({ + id: 'doing-b', + workspaceStatus: 'doing', + sortOrder: 1000, + manualOrder: 1000 + }) + ] + ]) + const rankByWorktreeId = new Map([ + ['todo-a', 4000], + ['filtered', 3000], + ['doing-a', 2000], + ['doing-b', 1000] + ]) + + const result = buildWorkspaceKanbanSidebarDropUpdates({ + worktreeIds: ['todo-a'], + status: 'doing', + dropIndex: 1, + groups: [ + { key: 'todo', worktreeIds: ['todo-a'] }, + { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } + ], + worktreeById, + allWorktreeIds: ['todo-a', 'filtered', 'doing-a', 'doing-b'], + rankByWorktreeId, + workspaceStatuses, + sortBy: 'manual', + now: 10_000 + }) + + expect([...result.updates]).toEqual([ + ['todo-a', { workspaceStatus: 'doing', manualOrder: 1500 }] + ]) }) }) 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 51dd561f94e..8672bbc5582 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -215,6 +215,8 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: { dropIndex: number groups: readonly WorktreeDragGroup[] worktreeById: ReadonlyMap + allWorktreeIds: readonly string[] + rankByWorktreeId: ReadonlyMap workspaceStatuses: readonly WorkspaceStatusDefinition[] sortBy: string now: number @@ -231,20 +233,6 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: { sourceGroupKeys, targetGroupKey: args.status }) - const rankByWorktreeId = writeManualOrder - ? (() => { - const ranks = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = args.worktreeById.get(worktreeId) - if (worktree) { - ranks.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - return ranks - })() - : undefined const order = writeManualOrder ? buildManualOrderUpdatesForGroupDrop({ groups: args.groups, @@ -252,7 +240,8 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: { draggedIds: args.worktreeIds, dropIndex: args.dropIndex, now: args.now, - rankByWorktreeId + rankByWorktreeId: args.rankByWorktreeId, + allWorktreeIds: args.allWorktreeIds }) : { changed: false, updates: new Map() } 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 db3716a0d8c..435d8e34cf2 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 @@ -16,30 +16,16 @@ import { import { buildWorkspaceKanbanSidebarDropUpdates } from '../../workspace-kanban-sidebar-drop' import type { SortBy } from '../../smart-sort' import type { WorktreeStatusDropAtIndexArgs } from './drop-commit-context' - -function buildRankByWorktreeId( - groups: readonly WorktreeDragGroup[], - worktreeMap: Map -): Map { - const rankByWorktreeId = new Map() - for (const group of groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - return rankByWorktreeId -} +import type { WorktreeManualOrderCatalog } from '../../worktree-manual-order-catalog' // Every write a sidebar drop can make: status changes, pin, manual order, and the board lane drop. export function useWorktreeStatusMutations(args: { worktreeMap: Map + manualOrderCatalog: WorktreeManualOrderCatalog workspaceStatuses: readonly WorkspaceStatusDefinition[] sortBy: SortBy }) { - const { worktreeMap, workspaceStatuses, sortBy } = args + const { manualOrderCatalog, worktreeMap, workspaceStatuses, sortBy } = args const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) const setSortBy = useAppStore((s) => s.setSortBy) @@ -81,7 +67,8 @@ export function useWorktreeStatusMutations(args: { draggedIds: dropArgs.worktreeIds, dropIndex: dropArgs.dropIndex, now: Date.now(), - rankByWorktreeId: buildRankByWorktreeId(dropArgs.groups, worktreeMap) + rankByWorktreeId: manualOrderCatalog.rankByWorktreeId, + allWorktreeIds: manualOrderCatalog.orderedIds }) const updates = new Map>() for (const worktreeId of dropArgs.worktreeIds) { @@ -112,7 +99,7 @@ export function useWorktreeStatusMutations(args: { } void updateWorktreesMeta(updates) }, - [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + [manualOrderCatalog, setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] ) const pinWorktree = useCallback( @@ -139,7 +126,8 @@ export function useWorktreeStatusMutations(args: { const result = buildManualOrderUpdatesForVisibleGroups({ ...reorderArgs, now: Date.now(), - rankByWorktreeId: buildRankByWorktreeId(reorderArgs.groups, worktreeMap) + rankByWorktreeId: manualOrderCatalog.rankByWorktreeId, + allWorktreeIds: manualOrderCatalog.orderedIds }) if (!result.changed) { return @@ -148,7 +136,7 @@ export function useWorktreeStatusMutations(args: { setSortBy('manual') void updateWorktreesMeta(result.updates) }, - [setSortBy, updateWorktreesMeta, worktreeMap] + [manualOrderCatalog, setSortBy, updateWorktreesMeta] ) const shouldShowWorkspaceBoardDropIndicator = useCallback( @@ -173,7 +161,9 @@ export function useWorktreeStatusMutations(args: { worktreeById: worktreeMap, workspaceStatuses, sortBy, - now: Date.now() + now: Date.now(), + allWorktreeIds: manualOrderCatalog.orderedIds, + rankByWorktreeId: manualOrderCatalog.rankByWorktreeId }) if (result.updates.size === 0) { return @@ -185,7 +175,7 @@ export function useWorktreeStatusMutations(args: { useAppStore.getState().recordFeatureInteraction('workspace-board-actions') void updateWorktreesMeta(result.updates) }, - [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + [manualOrderCatalog, setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] ) return { diff --git a/src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts new file mode 100644 index 00000000000..1460c9bb739 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-manual-order-catalog.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { worktree } from './worktree-list-groups-test-fixtures' +import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' + +function row(id: string, overrides: Partial = {}): Worktree { + return { ...worktree, id, displayName: id, ...overrides } +} + +function folder(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder', + folderPath: '/tmp/folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 200, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('buildWorktreeManualOrderCatalog', () => { + it('includes filtered-capable git and folder rows in fallback order', () => { + const catalog = buildWorktreeManualOrderCatalog({ + worktrees: [row('low', { sortOrder: 100 }), row('high', { sortOrder: 300 })], + folderWorkspaces: [folder()] + }) + + expect(catalog.orderedIds).toEqual(['high', 'folder:folder-1', 'low']) + expect(catalog.rankByWorktreeId.size).toBe(0) + }) + + it('treats a same-id host cluster as durable only when every owner agrees', () => { + const sameId = 'repo::/same' + const complete = buildWorktreeManualOrderCatalog({ + worktrees: [ + row(sameId, { hostId: 'local', manualOrder: 900 }), + row(sameId, { hostId: 'ssh:openclaw', manualOrder: 900 }) + ], + folderWorkspaces: [] + }) + const incomplete = buildWorktreeManualOrderCatalog({ + worktrees: [ + row(sameId, { hostId: 'local', manualOrder: 900 }), + row(sameId, { hostId: 'ssh:openclaw', manualOrder: undefined }) + ], + folderWorkspaces: [] + }) + + expect(complete.orderedIds).toEqual([sameId]) + expect(complete.rankByWorktreeId.get(sameId)).toBe(900) + expect(incomplete.rankByWorktreeId.has(sameId)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-manual-order-catalog.ts b/src/renderer/src/components/sidebar/worktree-manual-order-catalog.ts new file mode 100644 index 00000000000..fa273658eb5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-manual-order-catalog.ts @@ -0,0 +1,49 @@ +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import type { Worktree } from '../../../../shared/worktree/types' +import { compareWorktreeSortLabel } from './smart-sort' + +export type WorktreeManualOrderCatalog = { + orderedIds: readonly string[] + rankByWorktreeId: ReadonlyMap +} + +export function buildWorktreeManualOrderCatalog(args: { + worktrees: readonly Worktree[] + folderWorkspaces: readonly FolderWorkspace[] +}): WorktreeManualOrderCatalog { + const rows = [ + ...args.worktrees, + ...args.folderWorkspaces.map((workspace) => folderWorkspaceToWorktree(workspace)) + ] + .filter((row) => !row.isArchived) + .sort( + (left, right) => + (right.manualOrder ?? right.sortOrder) - (left.manualOrder ?? left.sortOrder) || + compareWorktreeSortLabel(left, right) + ) + const rowsById = new Map() + for (const row of rows) { + const matches = rowsById.get(row.id) + if (matches) { + matches.push(row) + } else { + rowsById.set(row.id, [row]) + } + } + + const orderedIds = [...rowsById.keys()] + const rankByWorktreeId = new Map() + for (const [worktreeId, matches] of rowsById) { + const ranks = matches.map((row) => row.manualOrder) + const rank = ranks[0] + if ( + typeof rank === 'number' && + Number.isFinite(rank) && + ranks.every((candidate) => candidate === rank) + ) { + rankByWorktreeId.set(worktreeId, rank) + } + } + return { orderedIds, rankByWorktreeId } +} diff --git a/src/renderer/src/components/sidebar/worktree-manual-order-ranks.ts b/src/renderer/src/components/sidebar/worktree-manual-order-ranks.ts index cba76f38332..a9ed7cb9323 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order-ranks.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order-ranks.ts @@ -15,6 +15,30 @@ function buildFallbackManualOrderUpdates( return updates } +function mergeVisibleOrderIntoKnownOrder( + knownIds: readonly string[], + visibleOrder: readonly string[] +): string[] { + const uniqueKnownIds = [...new Set(knownIds)] + const knownSet = new Set(uniqueKnownIds) + const uniqueVisibleOrder = [...new Set(visibleOrder)] + const knownVisibleOrder = uniqueVisibleOrder.filter((id) => knownSet.has(id)) + const visibleSet = new Set(knownVisibleOrder) + + const merged: string[] = [] + let visibleIndex = 0 + for (const id of uniqueKnownIds) { + if (visibleSet.has(id)) { + merged.push(knownVisibleOrder[visibleIndex] ?? id) + visibleIndex++ + continue + } + merged.push(id) + } + merged.push(...uniqueVisibleOrder.filter((id) => !knownSet.has(id))) + return merged +} + function getManualOrderRank( rankByWorktreeId: ReadonlyMap, worktreeId: string | undefined @@ -30,6 +54,8 @@ export function buildSparseManualOrderUpdates(args: { orderedIds: readonly string[] movedIds: readonly string[] rankByWorktreeId?: ReadonlyMap + /** Current order of every known row, including filtered/collapsed rows. */ + allWorktreeIds: readonly string[] now: number }): Map { const movedSet = new Set(args.movedIds) @@ -38,7 +64,20 @@ export function buildSparseManualOrderUpdates(args: { return new Map() } if (!args.rankByWorktreeId) { - return buildFallbackManualOrderUpdates(args.orderedIds, args.now) + return buildFallbackManualOrderUpdates( + mergeVisibleOrderIntoKnownOrder(args.allWorktreeIds, args.orderedIds), + args.now + ) + } + + // Why: Manual is a durable sequence, not a rank for only the rendered slice. + // Materialize every known row once when any row is still on the mutable + // sortOrder fallback; subsequent drags can use sparse ranks again. + if (args.allWorktreeIds.some((id) => getManualOrderRank(args.rankByWorktreeId!, id) === null)) { + return buildFallbackManualOrderUpdates( + mergeVisibleOrderIntoKnownOrder(args.allWorktreeIds, args.orderedIds), + args.now + ) } const firstMovedIndex = args.orderedIds.findIndex((id) => movedSet.has(id)) @@ -50,10 +89,16 @@ export function buildSparseManualOrderUpdates(args: { const nextRanks: number[] = [] if (beforeId !== undefined && beforeRank === null) { - return buildFallbackManualOrderUpdates(args.orderedIds, args.now) + return buildFallbackManualOrderUpdates( + mergeVisibleOrderIntoKnownOrder(args.allWorktreeIds, args.orderedIds), + args.now + ) } if (afterId !== undefined && afterRank === null) { - return buildFallbackManualOrderUpdates(args.orderedIds, args.now) + return buildFallbackManualOrderUpdates( + mergeVisibleOrderIntoKnownOrder(args.allWorktreeIds, args.orderedIds), + args.now + ) } if (beforeRank === null && afterRank === null) { @@ -74,7 +119,10 @@ export function buildSparseManualOrderUpdates(args: { // Why: repeated sparse inserts can eventually exhaust the numeric gap. // Re-index only in that rare dense case; ordinary drags persist moved rows. if (gap <= orderedMovedIds.length) { - return buildFallbackManualOrderUpdates(args.orderedIds, args.now) + return buildFallbackManualOrderUpdates( + mergeVisibleOrderIntoKnownOrder(args.allWorktreeIds, args.orderedIds), + args.now + ) } const step = gap / (orderedMovedIds.length + 1) for (let index = 0; index < orderedMovedIds.length; index++) { diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 5d7839956c5..ca962ace9c0 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -7,6 +7,90 @@ import { moveWorktreeIdsWithinGroup, shouldWriteManualOrderForGroupDrop } from './worktree-manual-order' +import { buildSparseManualOrderUpdates } from './worktree-manual-order-ranks' + +describe('buildSparseManualOrderUpdates durable migration', () => { + it('materializes filtered rows when the first drag creates Manual order', () => { + const result = buildSparseManualOrderUpdates({ + orderedIds: ['a', 'c', 'b'], + movedIds: ['b'], + allWorktreeIds: ['a', 'b', 'hidden', 'c'], + rankByWorktreeId: new Map([ + ['a', 4000], + ['b', 3000], + ['c', 1000] + ]), + now: 10_000 + }) + + expect([...result.keys()]).toEqual(['a', 'c', 'hidden', 'b']) + expect([...result.values()].map((update) => update.manualOrder)).toEqual([ + 10_000, 9000, 8000, 7000 + ]) + }) + + it('keeps sparse updates after every known row has a durable rank', () => { + const result = buildSparseManualOrderUpdates({ + orderedIds: ['a', 'c', 'b'], + movedIds: ['b'], + allWorktreeIds: ['a', 'b', 'hidden', 'c'], + rankByWorktreeId: new Map([ + ['a', 4000], + ['b', 3000], + ['hidden', 2000], + ['c', 1000] + ]), + now: 10_000 + }) + + expect([...result]).toEqual([['b', { manualOrder: 0 }]]) + }) + + it('never drops known rows when a stale visible sequence contains unknowns or duplicates', () => { + const result = buildSparseManualOrderUpdates({ + orderedIds: ['unknown', 'b', 'b', 'a'], + movedIds: ['b'], + allWorktreeIds: ['a', 'b', 'hidden', 'c', 'c'], + rankByWorktreeId: new Map(), + now: 10_000 + }) + + expect([...result.keys()]).toEqual(['b', 'a', 'hidden', 'c', 'unknown']) + }) + + it('preserves hidden rows when dense ranks require a full reindex', () => { + const result = buildSparseManualOrderUpdates({ + orderedIds: ['a', 'c', 'b'], + movedIds: ['c'], + allWorktreeIds: ['a', 'hidden', 'b', 'c'], + rankByWorktreeId: new Map([ + ['a', 3], + ['hidden', 2.5], + ['b', 2], + ['c', 1] + ]), + now: 10_000 + }) + + expect([...result.keys()]).toEqual(['a', 'hidden', 'c', 'b']) + }) + + it('preserves hidden rows when a stale neighbor requires a full reindex', () => { + const result = buildSparseManualOrderUpdates({ + orderedIds: ['stale', 'b', 'a'], + movedIds: ['b'], + allWorktreeIds: ['a', 'hidden', 'b'], + rankByWorktreeId: new Map([ + ['a', 3000], + ['hidden', 2000], + ['b', 1000] + ]), + now: 10_000 + }) + + expect([...result.keys()]).toEqual(['b', 'hidden', 'a', 'stale']) + }) +}) describe('expandDraggedWorktreeIdsForVisibleLineage', () => { it('expands an expanded lineage parent to its visible descendants for reordering', () => { @@ -238,6 +322,7 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => { sourceGroupKey: 'workspace-status:todo', draggedIds: ['todo-b'], dropIndex: 0, + allWorktreeIds: ['todo-a', 'todo-b', 'done-a', 'done-b'], now: 10_000 }) @@ -257,6 +342,7 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => { sourceGroupKey: 'repo:one', draggedIds: ['a'], dropIndex: 1, + allWorktreeIds: ['a', 'b'], now: 10_000 }) @@ -270,6 +356,7 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => { sourceGroupKey: 'repo:one', draggedIds: ['b'], dropIndex: 4, + allWorktreeIds: ['a', 'b', 'c', 'd'], now: 10_000, rankByWorktreeId: new Map([ ['a', 4000], @@ -289,6 +376,7 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => { sourceGroupKey: 'all', draggedIds: ['parent', 'child'], dropIndex: 3, + allWorktreeIds: ['parent', 'child', 'other'], now: 10_000, rankByWorktreeId: new Map([ ['parent', 3000], @@ -315,6 +403,7 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => { sourceGroupKey: 'all', draggedIds: ['wt-0'], dropIndex: ids.length, + allWorktreeIds: ids, now: 10_000, rankByWorktreeId }) @@ -337,6 +426,7 @@ describe('buildManualOrderUpdatesForGroupDrop', () => { targetGroupKey: 'doing', draggedIds: ['todo-b'], dropIndex: 1, + allWorktreeIds: ['todo-a', 'todo-b', 'doing-a', 'doing-b'], now: 10_000 }) @@ -359,6 +449,7 @@ describe('buildManualOrderUpdatesForGroupDrop', () => { targetGroupKey: 'doing', draggedIds: ['d', 'b'], dropIndex: 0, + allWorktreeIds: ['a', 'b', 'c', 'd'], now: 10_000 }) @@ -371,6 +462,7 @@ describe('buildManualOrderUpdatesForGroupDrop', () => { targetGroupKey: 'doing', draggedIds: ['a'], dropIndex: 1, + allWorktreeIds: ['a', 'b'], now: 10_000 }) @@ -387,6 +479,7 @@ describe('buildManualOrderUpdatesForGroupDrop', () => { targetGroupKey: 'doing', draggedIds: ['todo-b'], dropIndex: 1, + allWorktreeIds: ['todo-a', 'todo-b', 'doing-a', 'doing-b'], now: 10_000, rankByWorktreeId: new Map([ ['todo-a', 4000], diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.ts b/src/renderer/src/components/sidebar/worktree-manual-order.ts index 7a708c7be38..70978780252 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.ts @@ -119,6 +119,7 @@ export function buildManualOrderUpdatesForVisibleGroups(args: { dropIndex: number now: number rankByWorktreeId?: ReadonlyMap + allWorktreeIds: readonly string[] }): { changed: boolean orderedIds: string[] @@ -149,6 +150,7 @@ export function buildManualOrderUpdatesForVisibleGroups(args: { orderedIds, movedIds: args.draggedIds, rankByWorktreeId: args.rankByWorktreeId, + allWorktreeIds: args.allWorktreeIds, now: args.now }) } @@ -161,6 +163,7 @@ export function buildManualOrderUpdatesForGroupDrop(args: { dropIndex: number now: number rankByWorktreeId?: ReadonlyMap + allWorktreeIds: readonly string[] }): { changed: boolean orderedIds: string[] @@ -226,6 +229,7 @@ export function buildManualOrderUpdatesForGroupDrop(args: { orderedIds, movedIds: orderedDraggedIds, rankByWorktreeId: args.rankByWorktreeId, + allWorktreeIds: args.allWorktreeIds, now: args.now }) } diff --git a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts index fb83036ea8d..cf15e35e885 100644 --- a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts @@ -7,6 +7,7 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { createTestStore } from './store-test-helpers' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const folderWorkspacesUpdate = vi.fn() const folderWorkspacesDelete = vi.fn() @@ -69,6 +70,28 @@ beforeEach(() => { }) describe('folder workspace owner-routed mutations', () => { + it('persists manual rank through the shared batch metadata boundary', async () => { + const folderWorkspace = makeFolderWorkspace() + folderWorkspacesUpdate.mockResolvedValue({ ...folderWorkspace, manualOrder: 9000 }) + const store = createTestStore() + store.setState({ + projectGroups: [{ ...projectGroup, executionHostId: 'local' }], + folderWorkspaces: [folderWorkspace] + }) + + await store + .getState() + .updateWorktreesMeta( + new Map([[folderWorkspaceKey(folderWorkspace.id), { manualOrder: 9000 }]]) + ) + + expect(folderWorkspacesUpdate).toHaveBeenCalledWith({ + folderWorkspaceId: folderWorkspace.id, + updates: { manualOrder: 9000 } + }) + expect(store.getState().folderWorkspaces[0]?.manualOrder).toBe(9000) + }) + it('updates a local folder locally while another runtime is focused', async () => { const folderWorkspace = makeFolderWorkspace() folderWorkspacesUpdate.mockResolvedValue({ ...folderWorkspace, comment: 'Ready' }) diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index e9a953789bb..52cbef1b691 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -394,4 +394,38 @@ describe('worktree remote runtime mutations', () => { expect(subscriber).toHaveBeenCalledTimes(1) expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(2) }) + + it('persists a same-id manual rank to every owning host', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/same/path' + const local = makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'local' }) + const remote = makeWorktree({ + id: worktreeId, + repoId: 'repo1', + hostId: 'runtime:env-1' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-set-manual-order', + ok: true, + result: { worktree: { ...remote, manualOrder: 9000 } }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ worktreesByRepo: { repo1: [local, remote] } } as Partial) + + await store.getState().updateWorktreesMeta(new Map([[worktreeId, { manualOrder: 9000 }]])) + + expect(store.getState().worktreesByRepo.repo1.map((row) => row.manualOrder)).toEqual([ + 9000, 9000 + ]) + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId, + updates: { manualOrder: 9000 } + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.set', + params: { worktree: `id:${worktreeId}`, manualOrder: 9000 }, + timeoutMs: 15_000 + }) + }) }) diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts index ae691e4a424..00b8a1b676f 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts @@ -10,6 +10,7 @@ import { } from '../../../../../../shared/execution-host' import { resolveWorktreeOperationRoute, + resolveWorktreeOperationRouteForHost, settingsForWorktreeOperationRoute } from '@/lib/worktree-operation-route' import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from './worktree-slice-constants' @@ -97,9 +98,12 @@ export function trySettingsForWorktreeOwner( | 'runtimeEnvironmentCatalogHydrated' | 'removedRuntimeEnvironmentIds' >, - worktreeId: string + worktreeId: string, + executionHostId?: ExecutionHostId ): AppState['settings'] | null { - const route = resolveWorktreeOperationRoute(state, worktreeId) + const route = executionHostId + ? resolveWorktreeOperationRouteForHost(state, worktreeId, executionHostId) + : resolveWorktreeOperationRoute(state, worktreeId) if (!route) { return null } @@ -108,9 +112,10 @@ export function trySettingsForWorktreeOwner( export function settingsForWorktreeOwner( state: Parameters[0], - worktreeId: string + worktreeId: string, + executionHostId?: ExecutionHostId ) { - const settings = trySettingsForWorktreeOwner(state, worktreeId) + const settings = trySettingsForWorktreeOwner(state, worktreeId, executionHostId) if (!settings) { throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) } diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts index 33f4a224bdd..8ec3d887ae2 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts @@ -1,10 +1,31 @@ import type { WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { applyDetectedWorktreeUpdates } from '../listing/detected-worktree-meta' +import { + applyDetectedWorktreeUpdates, + getFolderWorkspaceMetaUpdates +} from '../listing/detected-worktree-meta' import { persistWorktreeMeta } from './worktree-meta-persist' import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' +import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' +import { getIndexedWorktreesById } from '../../../worktree-repo-index' +import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' + +function getKnownOwnerHostIds( + state: ReturnType, + worktreeId: string +): ExecutionHostId[] { + const hostIds = new Set() + for (const worktree of getIndexedWorktreesById(state.worktreesByRepo, worktreeId)) { + const hostId = parseExecutionHostId(worktree.hostId)?.id + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] +} export function createUpdateWorktreesMeta( set: WorktreeSliceSet, @@ -15,10 +36,30 @@ export function createUpdateWorktreesMeta( return } + const gitWorktreeUpdates = new Map>() + const folderWorkspaceUpdates: { + folderWorkspaceId: string + updates: ReturnType + }[] = [] + for (const [worktreeId, updates] of updatesByWorktreeId) { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const folderUpdates = getFolderWorkspaceMetaUpdates(updates) + if (Object.keys(folderUpdates).length > 0) { + folderWorkspaceUpdates.push({ + folderWorkspaceId: scope.folderWorkspaceId, + updates: folderUpdates + }) + } + } else { + gitWorktreeUpdates.set(worktreeId, updates) + } + } + set((s) => { let nextWorktrees = s.worktreesByRepo let nextDetectedWorktrees = s.detectedWorktreesByRepo - for (const [worktreeId, updates] of updatesByWorktreeId) { + for (const [worktreeId, updates] of gitWorktreeUpdates) { nextWorktrees = applyWorktreeUpdates(nextWorktrees, worktreeId, updates) nextDetectedWorktrees = applyDetectedWorktreeUpdates( nextDetectedWorktrees, @@ -39,14 +80,25 @@ export function createUpdateWorktreesMeta( } }) - await Promise.all( - Array.from(updatesByWorktreeId, async ([worktreeId, updates]) => { + await Promise.all([ + ...folderWorkspaceUpdates.map(({ folderWorkspaceId, updates }) => + get().updateFolderWorkspace(folderWorkspaceId, updates) + ), + ...Array.from(gitWorktreeUpdates, async ([worktreeId, updates]) => { try { - await persistWorktreeMeta( - settingsForWorktreeOwner(get(), worktreeId), - worktreeId, - updates - ) + const state = get() + const ownerHostIds = getKnownOwnerHostIds(state, worktreeId) + await (ownerHostIds.length === 0 + ? persistWorktreeMeta(settingsForWorktreeOwner(state, worktreeId), worktreeId, updates) + : Promise.all( + ownerHostIds.map((hostId) => + persistWorktreeMeta( + settingsForWorktreeOwner(state, worktreeId, hostId), + worktreeId, + updates + ) + ) + )) } catch (err) { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) @@ -56,6 +108,6 @@ export function createUpdateWorktreesMeta( void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) } }) - ) + ]) } } diff --git a/tests/e2e/manual-worktree-order-persistence.spec.ts b/tests/e2e/manual-worktree-order-persistence.spec.ts new file mode 100644 index 00000000000..0be47038960 --- /dev/null +++ b/tests/e2e/manual-worktree-order-persistence.spec.ts @@ -0,0 +1,231 @@ +import { expect, test } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { waitForSessionReady } from './helpers/store' + +async function visibleWorktreeIds(page: Page): Promise { + return page + .locator('[data-worktree-sidebar] [role="option"][data-worktree-id]') + .evaluateAll((elements) => + elements + .map((element) => ({ + id: element.getAttribute('data-worktree-id') ?? '', + top: element.getBoundingClientRect().top + })) + .sort((left, right) => left.top - right.top) + .map((entry) => entry.id) + ) +} + +async function dragBefore(page: Page, sourceId: string, targetId: string): Promise { + const source = page.locator(`[data-worktree-id=${JSON.stringify(sourceId)}]`).first() + const target = page.locator(`[data-worktree-id=${JSON.stringify(targetId)}]`).first() + await expect(source).toBeVisible() + await expect(target).toBeVisible() + const sourceBox = await source.boundingBox() + const targetBox = await target.boundingBox() + if (!sourceBox || !targetBox) { + throw new Error('Manual-order drag geometry was unavailable') + } + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 3, { steps: 8 }) + await page.mouse.up() +} + +test('manual drag survives activity and persisted-profile reload', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + const session = createRestartSession(testInfo) + let firstApp: Awaited>['app'] | null = null + let secondApp: Awaited>['app'] | null = null + let createdIds: string[] = [] + + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + await attachRepoAndOpenTerminal(first.page, testRepoPath) + createdIds = await first.page.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + const state = store.getState() + state.setGroupBy('none') + state.setSortBy('smart') + state.setShowSleepingWorkspaces(true) + const repoId = state.allWorktrees()[0]?.repoId + if (!repoId) { + throw new Error('seed repo was not loaded') + } + const names = ['manual-order-a', 'manual-order-b', 'manual-order-c'] + const created: string[] = [] + for (const name of names) { + created.push((await state.createWorktree(repoId, name, undefined, 'skip')).worktree.id) + } + return created + }) + + await expect + .poll( + () => + first.page + .locator('[data-worktree-sidebar] [role="option"][data-worktree-id]') + .evaluateAll( + (elements, ids) => + ids.every((id) => + elements.some((element) => element.getAttribute('data-worktree-id') === id) + ), + createdIds + ), + { timeout: 30_000 } + ) + .toBe(true) + const initialOrder = (await visibleWorktreeIds(first.page)).filter((id) => + createdIds.includes(id) + ) + const sourceId = createdIds.at(-1)! + const targetId = initialOrder.find((id) => id !== sourceId)! + await dragBefore(first.page, sourceId, targetId) + + const manualOrderAfterDrag = await first.page.evaluate((ids) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + return { + sortBy: state.sortBy, + rows: state + .allWorktrees() + .map((worktree) => ({ + id: worktree.id, + manualOrder: worktree.manualOrder, + sortOrder: worktree.sortOrder + })) + .filter((row) => ids.includes(row.id)) + } + }, createdIds) + expect(manualOrderAfterDrag.sortBy).toBe('manual') + expect(manualOrderAfterDrag.rows.every((row) => Number.isFinite(row.manualOrder))).toBe(true) + const expectedOrder = [...manualOrderAfterDrag.rows] + .sort((left, right) => (right.manualOrder ?? 0) - (left.manualOrder ?? 0)) + .map((row) => row.id) + expect((await visibleWorktreeIds(first.page)).filter((id) => createdIds.includes(id))).toEqual( + expectedOrder + ) + + const sortOrdersBeforeActivity = new Map( + manualOrderAfterDrag.rows.map((row) => [row.id, row.sortOrder]) + ) + await first.page.evaluate(async (activityId) => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + store.getState().bumpWorktreeActivity(activityId) + const orderedIds = store + .getState() + .allWorktrees() + .map((worktree) => worktree.id) + .filter((id) => id !== activityId) + await window.api.worktrees.persistSortOrder({ orderedIds: [activityId, ...orderedIds] }) + await store.getState().fetchAllWorktrees() + }, targetId) + await expect + .poll(() => + first.page.evaluate((activityId) => { + const row = window.__store + ?.getState() + .allWorktrees() + .find((worktree) => worktree.id === activityId) + return row?.sortOrder + }, targetId) + ) + .not.toBe(sortOrdersBeforeActivity.get(targetId)) + await expect + .poll(() => + visibleWorktreeIds(first.page).then((ids) => ids.filter((id) => createdIds.includes(id))) + ) + .toEqual(expectedOrder) + + await expect + .poll(async () => { + const [ui, worktrees] = await first.page.evaluate(async (ids) => { + const [persistedUi, persistedWorktrees] = await Promise.all([ + window.api.ui.get(), + window.api.worktrees.listAll() + ]) + return [ + persistedUi, + persistedWorktrees + .filter((worktree) => ids.includes(worktree.id)) + .map((worktree) => ({ id: worktree.id, manualOrder: worktree.manualOrder })) + ] as const + }, createdIds) + return { + sortBy: ui.sortBy, + ranks: Object.fromEntries(worktrees.map((row) => [row.id, row.manualOrder])) + } + }) + .toEqual({ + sortBy: 'manual', + ranks: Object.fromEntries(manualOrderAfterDrag.rows.map((row) => [row.id, row.manualOrder])) + }) + + await session.close(firstApp) + firstApp = null + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + const reloadedState = await second.page.evaluate((ids) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('store unavailable') + } + return { + sortBy: state.sortBy, + rows: state + .allWorktrees() + .filter((worktree) => ids.includes(worktree.id)) + .map((worktree) => ({ id: worktree.id, manualOrder: worktree.manualOrder })) + } + }, createdIds) + expect(reloadedState.sortBy).toBe('manual') + const reloadedRows = reloadedState.rows + expect(reloadedRows.every((row) => Number.isFinite(row.manualOrder))).toBe(true) + expect(reloadedRows.map((row) => row.id).sort()).toEqual(expectedOrder.slice().sort()) + expect(new Map(reloadedRows.map((row) => [row.id, row.manualOrder]))).toEqual( + new Map(manualOrderAfterDrag.rows.map((row) => [row.id, row.manualOrder])) + ) + await expect + .poll( + () => + visibleWorktreeIds(second.page).then((ids) => + ids.filter((id) => createdIds.includes(id)) + ), + { timeout: 30_000 } + ) + .toEqual(expectedOrder) + } finally { + if (secondApp && createdIds.length > 0) { + await secondApp + .firstWindow() + .then((page) => + page.evaluate(async (ids) => { + for (const id of ids) { + await window.__store?.getState().removeWorktree({ id, executionHostId: null }, true) + } + }, createdIds) + ) + .catch(() => undefined) + } + for (const app of [secondApp, firstApp]) { + if (app) { + await session.close(app).catch(() => undefined) + } + } + await session.dispose() + } +})