fix(sidebar): preserve hidden rows in manual order (#16488)

This commit is contained in:
Jinwoo Hong
2026-08-25 16:46:42 -07:00
committed by GitHub
parent 0e0a8c943b
commit c8567eb16e
17 changed files with 727 additions and 71 deletions
@@ -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
@@ -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<HTMLDivElement>(null)
const areaSelectionOverlayRef = useRef<HTMLDivElement>(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
@@ -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<number>
@@ -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) => {
@@ -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<typeof useAppStore.getState>['updateWorktreesMeta']
workspaceStatuses: ReturnType<typeof useAppStore.getState>['workspaceStatuses']
worktreeById: ReadonlyMap<string, Worktree>
manualOrderCatalog: WorktreeManualOrderCatalog
worktreesByStatus: ReadonlyMap<string, readonly Worktree[]>
}) {
const recordInteraction = (): void => {
@@ -88,16 +90,6 @@ export function useWorkspaceKanbanWorktreeActions(args: {
const updates = new Map<string, Partial<WorktreeMeta>>()
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<string, { manualOrder: number }>() }
for (const worktreeId of drop.worktreeIds) {
@@ -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 }]
])
})
})
@@ -215,6 +215,8 @@ export function buildWorkspaceKanbanSidebarDropUpdates(args: {
dropIndex: number
groups: readonly WorktreeDragGroup[]
worktreeById: ReadonlyMap<string, Worktree>
allWorktreeIds: readonly string[]
rankByWorktreeId: ReadonlyMap<string, number>
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<string, number>()
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<string, { manualOrder: number }>() }
@@ -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<string, Worktree>
): Map<string, number> {
const rankByWorktreeId = new Map<string, number>()
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<string, Worktree>
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<string, Partial<WorktreeMeta>>()
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 {
@@ -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> = {}): Worktree {
return { ...worktree, id, displayName: id, ...overrides }
}
function folder(overrides: Partial<FolderWorkspace> = {}): 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)
})
})
@@ -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<string, number>
}
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<string, Worktree[]>()
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<string, number>()
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 }
}
@@ -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<string, number>,
worktreeId: string | undefined
@@ -30,6 +54,8 @@ export function buildSparseManualOrderUpdates(args: {
orderedIds: readonly string[]
movedIds: readonly string[]
rankByWorktreeId?: ReadonlyMap<string, number>
/** Current order of every known row, including filtered/collapsed rows. */
allWorktreeIds: readonly string[]
now: number
}): Map<string, WorktreeManualOrderUpdate> {
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++) {
@@ -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],
@@ -119,6 +119,7 @@ export function buildManualOrderUpdatesForVisibleGroups(args: {
dropIndex: number
now: number
rankByWorktreeId?: ReadonlyMap<string, number>
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<string, number>
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
})
}
@@ -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' })
@@ -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<AppState>)
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
})
})
})
@@ -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<typeof trySettingsForWorktreeOwner>[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)
}
@@ -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<WorktreeSliceGet>,
worktreeId: string
): ExecutionHostId[] {
const hostIds = new Set<ExecutionHostId>()
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<string, Partial<WorktreeMeta>>()
const folderWorkspaceUpdates: {
folderWorkspaceId: string
updates: ReturnType<typeof getFolderWorkspaceMetaUpdates>
}[] = []
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))
}
})
)
])
}
}
@@ -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<string[]> {
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<void> {
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<ReturnType<typeof session.launch>>['app'] | null = null
let secondApp: Awaited<ReturnType<typeof session.launch>>['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()
}
})