mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(sidebar): preserve hidden rows in manual order (#16488)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user