diff --git a/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts new file mode 100644 index 00000000000..c6f6fdf11a1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-expansion.performance.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { + expandDraggedWorktreeIdsForVisibleLineage, + type WorktreeDragLineageRow +} from './worktree-manual-order' + +// Preserve the original traversal as an independent ordering oracle. +function referenceExpansion( + rows: readonly WorktreeDragLineageRow[], + draggedIds: readonly string[] +) { + const dragged = new Set(draggedIds) + const expanded = new Set(draggedIds) + const rowIds = new Set(rows.map((row) => row.worktreeId)) + for (let index = 0; index < rows.length; index++) { + const row = rows[index] + if (!dragged.has(row.worktreeId)) { + continue + } + for (let cursor = index + 1; cursor < rows.length; cursor++) { + const child = rows[cursor] + if (child.depth <= row.depth) { + break + } + expanded.add(child.worktreeId) + } + } + const result = rows.filter((row) => expanded.has(row.worktreeId)).map((row) => row.worktreeId) + for (const id of draggedIds) { + if (!rowIds.has(id) && !result.includes(id)) { + result.push(id) + } + } + return result +} + +describe('visible lineage expansion scaling', () => { + it('reads each depth once even when every ancestor is selected', () => { + let depthReads = 0 + const rows = Array.from({ length: 2_000 }, (_, index) => ({ + worktreeId: `wt-${index}`, + get depth() { + depthReads++ + return index + } + })) + const selected = rows.map((row) => row.worktreeId) + expect(referenceExpansion(rows, selected)).toEqual(selected) + expect(depthReads).toBe(rows.length * (rows.length - 1)) + depthReads = 0 + expect(expandDraggedWorktreeIdsForVisibleLineage(rows, selected)).toEqual(selected) + expect(depthReads).toBe(rows.length) + }) + + it('deduplicates many absent selected rows without searching the growing result', () => { + const selected = Array.from({ length: 5_000 }, (_, index) => `missing-${index}`) + const includes = vi.spyOn(Array.prototype, 'includes') + let calls: number + let result: string[] + try { + result = expandDraggedWorktreeIdsForVisibleLineage([], [...selected, ...selected]) + calls = includes.mock.calls.length + } finally { + includes.mockRestore() + } + expect(result).toEqual(selected) + expect(calls).toBe(0) + }) + + it('matches the previous algorithm across duplicate rows, missing IDs and depth boundaries', () => { + let seed = 12345 + function random(limit: number): number { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed % limit + } + const depths = [-1, 0, 1, 2, 3, 20, Infinity, -Infinity, Number.NaN] + for (let sample = 0; sample < 500; sample++) { + const rows = Array.from({ length: random(40) }, () => ({ + worktreeId: `wt-${random(20)}`, + depth: depths[random(depths.length)] + })) + const selected = Array.from({ length: random(20) }, () => `wt-${random(25)}`) + expect(expandDraggedWorktreeIdsForVisibleLineage(rows, selected)).toEqual( + referenceExpansion(rows, selected) + ) + } + }) +}) 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 ca962ace9c0..6992f0f98b1 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -120,6 +120,44 @@ describe('expandDraggedWorktreeIdsForVisibleLineage', () => { ) ).toEqual(['parent', 'child', 'z']) }) + + // Single-pass coverage must not end early when a descendant is also selected. + it('keeps ancestor coverage when a nested descendant is selected too', () => { + expect( + expandDraggedWorktreeIdsForVisibleLineage( + [ + { worktreeId: 'parent', depth: 0 }, + { worktreeId: 'child', depth: 2 }, + { worktreeId: 'uncleish', depth: 1 }, + { worktreeId: 'sibling', depth: 0 } + ], + ['parent', 'child'] + ) + ).toEqual(['parent', 'child', 'uncleish']) + }) + + it('restarts coverage for a later selected sibling after the previous run ends', () => { + expect( + expandDraggedWorktreeIdsForVisibleLineage( + [ + { worktreeId: 'first', depth: 0 }, + { worktreeId: 'first-child', depth: 1 }, + { worktreeId: 'second', depth: 0 }, + { worktreeId: 'second-child', depth: 1 } + ], + ['first', 'second'] + ) + ).toEqual(['first', 'first-child', 'second', 'second-child']) + }) + + it('emits each absent selected id once', () => { + expect( + expandDraggedWorktreeIdsForVisibleLineage( + [{ worktreeId: 'row', depth: 0 }], + ['gone', 'row', 'gone'] + ) + ).toEqual(['row', 'gone']) + }) }) describe('moveWorktreeIdsWithinGroup', () => { diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.ts b/src/renderer/src/components/sidebar/worktree-manual-order.ts index 70978780252..887a5f17213 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.ts @@ -41,17 +41,18 @@ export function expandDraggedWorktreeIdsForVisibleLineage( const expandedSet = new Set(draggedIds) const rowIdSet = new Set(rows.map((row) => row.worktreeId)) - for (let index = 0; index < rows.length; index++) { - const row = rows[index]! - if (!draggedSet.has(row.worktreeId)) { - continue + let ancestorDepth: number | undefined + for (const row of rows) { + const depth = row.depth + if (ancestorDepth !== undefined && depth <= ancestorDepth) { + ancestorDepth = undefined } - for (let cursor = index + 1; cursor < rows.length; cursor++) { - const child = rows[cursor]! - if (child.depth <= row.depth) { - break - } - expandedSet.add(child.worktreeId) + if (ancestorDepth !== undefined) { + expandedSet.add(row.worktreeId) + } + // Nested selections are already covered; NaN preserves an unterminated legacy scan. + if (draggedSet.has(row.worktreeId) && (ancestorDepth === undefined || Number.isNaN(depth))) { + ancestorDepth = depth } } @@ -62,8 +63,9 @@ export function expandDraggedWorktreeIdsForVisibleLineage( } } for (const id of draggedIds) { - if (!rowIdSet.has(id) && !expandedIds.includes(id)) { + if (!rowIdSet.has(id)) { expandedIds.push(id) + rowIdSet.add(id) } } return expandedIds