From bdcae110abdc373d1f5f37d16eefdcdffa983438 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 5 Nov 2026 04:18:17 -0800 Subject: [PATCH] test: remove low-value sidebar tests (#38525) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 13 - .../worktree-sidebar-drop-preview.test.ts | 334 ------- .../build-rows.folder-workspace-lanes.test.ts | 258 ------ .../worktree-list/grouping/build-rows.test.ts | 357 -------- .../host-section-project-headers.test.ts | 238 ----- .../grouping/host-section-rows.test.ts | 866 ------------------ ...ree-list-groups-imported-worktrees.test.ts | 527 ----------- ...rktree-list-groups-lineage-nesting.test.ts | 546 +---------- ...-list-groups-nested-project-groups.test.ts | 418 --------- ...orktree-list-groups-project-groups.test.ts | 462 ---------- ...ee-list-groups-project-host-setups.test.ts | 686 +------------- ...worktree-list-groups-project-order.test.ts | 478 +--------- .../WorktreeList.card-memo-stability.test.tsx | 327 ------- ...WorktreeList.folder-workspace-rows.test.ts | 175 ---- ....lineage-agent-expansion-coupling.test.tsx | 33 - ...ktreeList.lineage-child-real-card.test.tsx | 627 ------------- ...treeList.status-lane-lineage-drop.test.tsx | 460 ---------- .../agent-send-picker-collapse.test.tsx | 238 ----- .../listing/host-status-isolation.test.tsx | 351 +------ .../rendered-sidebar-worktree-order.test.ts | 208 +---- .../listing/use-sort-order.test.tsx | 229 ----- 22 files changed, 11 insertions(+), 7821 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drop-preview.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/host-section-project-headers.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/host-section-rows.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-imported-worktrees.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-nested-project-groups.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-groups.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.card-memo-stability.test.tsx delete mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.folder-workspace-rows.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-child-real-card.test.tsx delete mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.status-lane-lineage-drop.test.tsx delete mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/agent-send-picker-collapse.test.tsx diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index cc1e14e5bcc..adaaf876f51 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -535,7 +535,6 @@ 1 test-no-wall-clock:src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx 1 test-no-wall-clock:src/renderer/src/components/settings/accounts/providers/GrokAccountsSection.test.tsx 1 test-no-wall-clock:src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx -1 test-no-wall-clock:src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-child-real-card.test.tsx 1 test-no-wall-clock:src/renderer/src/components/tab-bar/open-tab-search.test.ts 1 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-saved-filter-load-failure.test.tsx 1 test-no-wall-clock:src/renderer/src/components/terminal-pane/cache-timer-seeding.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 435f89609a3..b89bf06ccce 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -7829,18 +7829,13 @@ "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.single-reference-details.test.tsx": 2032, "src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenu.closed-menu-work.test.tsx": 1753, "src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenu.test.ts": 299, - "src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.card-memo-stability.test.tsx": 2053, - "src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.folder-workspace-rows.test.ts": 8880, "src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx": 5388, - "src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-child-real-card.test.tsx": 4426, - "src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.status-lane-lineage-drop.test.tsx": 3576, "src/renderer/src/components/sidebar/workspace-card/metadata/WorktreeMetaDialog.host-scope.test.tsx": 2215, "src/renderer/src/components/sidebar/workspace-card/metadata/WorktreeMetaDialog.test.tsx": 7694, "src/renderer/src/components/sidebar/workspace-card/identity/WorktreeTitleInlineRename.editor-lifecycle.test.tsx": 407, "src/renderer/src/components/sidebar/workspace-visibility/dialog/WorktreeVisibilityDialog.test.tsx": 2602, "src/renderer/src/components/sidebar/host-admission/ssh/add-remote-host-ssh-actions.test.ts": 232, "src/renderer/src/components/sidebar/project-admission/completion/add-repo-skip-finalization.test.ts": 59, - "src/renderer/src/components/sidebar/worktree-list/listing/agent-send-picker-collapse.test.tsx": 2284, "src/renderer/src/components/sidebar/chat-markdown-math-typeset.test.ts": 1336, "src/renderer/src/components/sidebar/worktree-list/grouping/default-branch-folder-project-root.test.ts": 227, "src/renderer/src/components/sidebar/worktree-list/grouping/default-branch-visible-under-hide-sleeping.test.ts": 261, @@ -7854,9 +7849,7 @@ "src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts": 2264, "src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts": 2665, "src/renderer/src/components/sidebar/worktree-list/drag/host-header-drag.test.tsx": 281, - "src/renderer/src/components/sidebar/worktree-list/grouping/host-section-project-headers.test.ts": 308, "src/renderer/src/components/sidebar/worktree-list/grouping/host-section-reveal.test.tsx": 2294, - "src/renderer/src/components/sidebar/worktree-list/grouping/host-section-rows.test.ts": 83, "src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts": 52, "src/renderer/src/components/sidebar/worktree-list/listing/manual-sort-switch-toast.test.ts": 1753, "src/renderer/src/components/sidebar/markdown-top-level-blocks.test.ts": 543, @@ -7905,15 +7898,10 @@ "src/renderer/src/components/sidebar/worktree-list/navigation/worktree-keyboard-cycle.test.ts": 131, "src/renderer/src/components/sidebar/worktree-list/drag/worktree-lineage-drag-drop.test.ts": 200, "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-lineage-projection.test.ts": 51, - "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-imported-worktrees.test.ts": 251, "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-lineage-nesting.test.ts": 241, - "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-nested-project-groups.test.ts": 248, - "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-groups.test.ts": 239, "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-host-setups.test.ts": 280, "src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-order.test.ts": 1861, "src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts": 517, - "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 271, - "src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 234, "src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts": 248, "src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts": 59, "src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts": 183, @@ -7944,7 +7932,6 @@ "src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order-store-write.test.ts": 2144, "src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order.test.ts": 332, "src/renderer/src/components/sidebar/workspace-actions/parenting/worktree-parent-eligibility.test.ts": 68, - "src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drop-preview.test.ts": 80, "src/renderer/src/components/sidebar/worktree-list/listing/worktree-sort-label-ordering.test.ts": 114, "src/renderer/src/components/sidebar/wsl-host-offer-move.test.ts": 227, "src/renderer/src/components/sidebar/wsl-host-offer.test.ts": 1765, diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drop-preview.test.ts deleted file mode 100644 index 53fd2d60d19..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drop-preview.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - computeWorktreeSidebarDropPreview, - resolveWorktreeSidebarStatusDropCommitTarget -} from './worktree-sidebar-drop-preview' - -const rects = [ - { worktreeId: 'done-a', groupIndex: 0, top: 80, bottom: 120 }, - { worktreeId: 'done-b', groupIndex: 1, top: 132, bottom: 172 } -] - -describe('computeWorktreeSidebarDropPreview', () => { - it('computes an insertion line for a target group', () => { - expect( - computeWorktreeSidebarDropPreview({ - pointerY: 151, - containerTop: 100, - scrollTop: 100, - rects, - groupIds: ['done-a', 'done-b'], - draggedIds: ['in-progress-a'] - }) - ).toMatchObject({ - dropIndex: 1, - dropIndicatorY: 129 - }) - }) - - it('returns null outside the group boundary', () => { - expect( - computeWorktreeSidebarDropPreview({ - pointerY: -20, - containerTop: 100, - scrollTop: 100, - rects, - groupIds: ['done-a', 'done-b'], - draggedIds: ['in-progress-a'] - }) - ).toBeNull() - }) - - it('collapses lineage child rects into the parent drag unit for preview offsets', () => { - const preview = computeWorktreeSidebarDropPreview({ - pointerY: 430, - containerTop: 0, - scrollTop: 0, - rects: [ - { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 90 }, - { worktreeId: 'child-a', groupIndex: 1, top: 96, bottom: 186 }, - { worktreeId: 'child-b', groupIndex: 2, top: 192, bottom: 282 }, - { worktreeId: 'sibling', groupIndex: 3, top: 288, bottom: 388 } - ], - groupIds: ['parent', 'sibling'], - draggedIds: ['parent'] - }) - - // The parent lands after the sibling at 106px; mark 103px rather than its old bottom at 391px. - expect(preview).toMatchObject({ - dropIndex: 2, - dropIndicatorY: 103 - }) - expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['sibling', -288]]) - }) - - it('uses one card-height placeholder for multi-select reorder previews', () => { - const preview = computeWorktreeSidebarDropPreview({ - pointerY: 280, - containerTop: 0, - scrollTop: 0, - rects: [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, - { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, - { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, - { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, - { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } - ], - groupIds: ['a', 'b', 'c', 'd', 'e'], - draggedIds: ['b', 'c', 'd'], - draggingWorktreeId: 'b' - }) - - // Rows slide upward to open a slot at 224px; mark 221px rather than the old 277px slot. - expect(preview).toMatchObject({ - dropIndex: 5, - dropIndicatorY: 221 - }) - expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([ - ['c', -56], - ['d', -56], - ['e', -56] - ]) - }) - - it('uses the grabbed selected card as the multi-select preview placeholder', () => { - const preview = computeWorktreeSidebarDropPreview({ - pointerY: 280, - containerTop: 0, - scrollTop: 0, - rects: [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, - { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, - { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, - { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, - { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } - ], - groupIds: ['a', 'b', 'c', 'd', 'e'], - draggedIds: ['b', 'c', 'd'], - draggingWorktreeId: 'd' - }) - - expect(preview).toMatchObject({ - dropIndex: 5, - dropIndicatorY: 221 - }) - expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['e', -56]]) - }) - - it('marks the gap the row previews open, not the displaced card top', () => { - // An expanded 404px neighbor exposes card-height errors in insertion-line placement. - const rects = [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 116 }, - { worktreeId: 'b', groupIndex: 1, top: 122, bottom: 238 }, - { worktreeId: 'expanded', groupIndex: 2, top: 244, bottom: 648 }, - { worktreeId: 'd', groupIndex: 3, top: 654, bottom: 770 } - ] - const groupIds = ['a', 'b', 'expanded', 'd'] - const preview = computeWorktreeSidebarDropPreview({ - pointerY: 700, - containerTop: 0, - scrollTop: 0, - rects, - groupIds, - draggedIds: ['a'], - draggingWorktreeId: 'a', - grab: { offsetY: 58, height: 116 } - })! - - // 'a' lands last, so every other card slides up by its 116px + 6px gap. - const offsets = preview.previewOffsetsByWorktreeId - expect(offsets.get('b')).toBe(-122) - expect(offsets.get('expanded')).toBe(-122) - expect(offsets.get('d')).toBe(-122) - // d ends at 532..648, so a's slot opens at 654 and the line marks 651. - expect(preview.dropIndicatorY).toBe(651) - }) - - it('resolves the same slot wherever a tall card is grabbed', () => { - const rects = [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 116 }, - { worktreeId: 'b', groupIndex: 1, top: 122, bottom: 238 }, - { worktreeId: 'expanded', groupIndex: 2, top: 244, bottom: 648 } - ] - const groupIds = ['a', 'b', 'expanded'] - const height = 404 - const dropIndexes = [0.05, 0.5, 0.95].map((fraction) => { - const offsetY = height * fraction - return computeWorktreeSidebarDropPreview({ - pointerY: 122 + offsetY, - containerTop: 0, - scrollTop: 0, - rects, - groupIds, - draggedIds: ['expanded'], - draggingWorktreeId: 'expanded', - grab: { offsetY, height } - })!.dropIndex - }) - - expect(new Set(dropIndexes).size).toBe(1) - }) - - it('keeps a downward end drop stable when leading rows are virtualized', () => { - const groupIds = ['a', 'b', 'c', 'd', 'e', 'f'] - const mountedRects = [ - { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, - { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, - { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } - ] - const input = { - pointerY: 320, - containerTop: 0, - scrollTop: 0, - rects: mountedRects, - groupIds, - draggedIds: ['a'], - draggingWorktreeId: 'a', - grab: { offsetY: 25, height: 50 } - } - - const first = computeWorktreeSidebarDropPreview(input)! - const held = computeWorktreeSidebarDropPreview({ - ...input, - anchor: { beforeWorktreeId: first.dropAnchorId, pointerY: 320, scrollTop: 0 } - })! - - for (const preview of [first, held]) { - expect(preview).toMatchObject({ - dropIndex: 6, - dropIndicatorY: 277, - dropAnchorId: null - }) - expect(Array.from(preview.previewOffsetsByWorktreeId)).toEqual([ - ['d', -56], - ['e', -56], - ['f', -56] - ]) - } - }) - - it('uses the full group index when the dragged row is outside the mounted window', () => { - const preview = computeWorktreeSidebarDropPreview({ - pointerY: 193, - containerTop: 0, - scrollTop: 0, - rects: [ - { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, - { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }, - { worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 } - ], - groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], - draggedIds: ['e'], - draggingWorktreeId: 'e', - grab: { offsetY: 25, height: 50 } - }) - - expect(preview?.dropIndex).toBe(3) - }) - - it('preserves the virtual row gap when only one row remains mounted', () => { - const preview = computeWorktreeSidebarDropPreview({ - pointerY: 305, - containerTop: 0, - scrollTop: 0, - rects: [{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }], - groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], - draggedIds: ['a'], - draggingWorktreeId: 'a', - fallbackGap: 6, - grab: { offsetY: 25, height: 50 } - }) - - expect(preview).toMatchObject({ dropIndex: 6, dropIndicatorY: 277 }) - expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['f', -56]]) - }) -}) - -describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { - const preview = { - dropIndex: 1, - dropIndicatorY: 129, - previewOffsetsByWorktreeId: new Map(), - dropAnchorId: null - } - - it('uses the current status target when pointerup hit-testing succeeds', () => { - expect( - resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: 'completed', isPinDrop: false, lineageParentId: null }, - currentPreview: preview, - latestTrackedTarget: { - target: { status: 'in-progress', isPinDrop: false, lineageParentId: null }, - preview: null, - x: 100, - y: 100 - }, - x: 100, - y: 100 - }) - ).toEqual({ - target: { status: 'completed', isPinDrop: false, lineageParentId: null }, - preview - }) - }) - - it('reuses the latest status target when pointerup hit-testing blanks at the same point', () => { - expect( - resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: null, isPinDrop: false, lineageParentId: null }, - currentPreview: null, - latestTrackedTarget: { - target: { status: 'completed', isPinDrop: false, lineageParentId: null }, - preview, - x: 100, - y: 100 - }, - x: 102, - y: 101 - }) - ).toEqual({ - target: { status: 'completed', isPinDrop: false, lineageParentId: null }, - preview - }) - }) - - it('reuses the latest lineage target when pointerup hit-testing blanks at the same point', () => { - expect( - resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: null, isPinDrop: false, lineageParentId: null }, - currentPreview: null, - latestTrackedTarget: { - target: { status: null, isPinDrop: false, lineageParentId: 'parent-worktree' }, - preview: null, - x: 100, - y: 100 - }, - x: 102, - y: 101 - }) - ).toEqual({ - target: { status: null, isPinDrop: false, lineageParentId: 'parent-worktree' }, - preview: null - }) - }) - - it('does not reuse a stale status target after the pointer has moved away', () => { - expect( - resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: null, isPinDrop: false, lineageParentId: null }, - currentPreview: null, - latestTrackedTarget: { - target: { status: 'completed', isPinDrop: false, lineageParentId: null }, - preview, - x: 100, - y: 100 - }, - x: 140, - y: 100 - }) - ).toEqual({ - target: { status: null, isPinDrop: false, lineageParentId: null }, - preview: null - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts deleted file mode 100644 index d2094cf2285..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildRows } from './build-rows.test-fixture' -import { getFolderWorkspaceLaneKey } from '../../../../../../shared/sidebar-list/folder-workspace-lanes' -import { getPRLaneKey } from '../../../../../../shared/sidebar-list/group-keys' -import { getPRGroupKey } from './pr-group-key' -import { getRenderRowKey } from '../listing/render-row' -import type { Row, WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types' -import { repo, worktree } from './worktree-list-groups-test-fixtures' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' - -const GROUP: ProjectGroup = { - id: 'group-1', - name: 'Multi-repo Group', - parentPath: '/tmp/parent', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} - -function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { - return { - id: 'fw-1', - projectGroupId: GROUP.id, - name: 'Folder workspace', - folderPath: '/tmp/parent', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - workspaceStatus: 'in-progress', - ...overrides - } -} - -const GROUPED_REPO: Repo = { ...repo, projectGroupId: GROUP.id } - -function buildSidebarRows(options: { - groupBy: WorktreeGroupBy - folderWorkspaces?: readonly FolderWorkspace[] - projectGroups?: readonly ProjectGroup[] - worktrees?: (typeof worktree)[] - collapsedGroups?: Set -}): Row[] { - const worktrees = options.worktrees ?? [worktree] - return buildRows( - options.groupBy, - worktrees, - new Map([[GROUPED_REPO.id, GROUPED_REPO]]), - null, - options.collapsedGroups ?? new Set(), - undefined, - undefined, - 'manual', - {}, - new Map(worktrees.map((entry) => [entry.id, entry])), - false, - undefined, - options.projectGroups ?? [GROUP], - new Set(), - new Map(), - new Map(), - [], - undefined, - options.folderWorkspaces ?? [makeFolderWorkspace()] - ) -} - -function folderRows(rows: Row[]): Extract[] { - return rows.filter( - (row): row is Extract => row.type === 'folder-workspace' - ) -} - -const ALL_GROUP_BY: WorktreeGroupBy[] = ['repo', 'workspace-status', 'pr-status', 'none'] - -describe('folder workspaces render under every Group by mode', () => { - // Non-repo modes exercise the fix; repo mode guards existing behavior. - for (const groupBy of ALL_GROUP_BY) { - it(`emits the folder-workspace row when groupBy is ${groupBy}`, () => { - expect(folderRows(buildSidebarRows({ groupBy }))).toHaveLength(1) - }) - } - - it('does not duplicate the row under repo grouping', () => { - const rows = buildSidebarRows({ groupBy: 'repo' }) - expect(folderRows(rows).map((row) => row.key)).toEqual(['folder-workspace:local|folder:fw-1']) - }) - - it('keys the same folder id on two hosts apart, as the desktop draws them', () => { - const folders = [ - makeFolderWorkspace(), - makeFolderWorkspace({ executionHostId: 'runtime:env-1', connectionId: null }), - makeFolderWorkspace({ connectionId: 'gpu-vm' }) - ] - const rows = folderRows(buildSidebarRows({ groupBy: 'none', folderWorkspaces: folders })) - expect(new Set(rows.map((row) => row.key))).toEqual( - new Set([ - 'folder-workspace:local|folder:fw-1', - 'folder-workspace:runtime:env-1|folder:fw-1', - 'folder-workspace:ssh:gpu-vm|folder:fw-1' - ]) - ) - expect(rows.map(getRenderRowKey)).toEqual(rows.map((row) => row.key)) - }) -}) - -describe('a folder workspace can be the only member of a lane', () => { - it('creates its status lane with no worktrees present', () => { - const rows = buildSidebarRows({ groupBy: 'workspace-status', worktrees: [] }) - expect(folderRows(rows)).toHaveLength(1) - const header = rows.find((row) => row.type === 'header') - expect(header).toBeDefined() - expect(header && 'count' in header ? header.count : null).toBe(1) - }) - - it('renders in flat mode with no worktrees present', () => { - const rows = buildSidebarRows({ groupBy: 'none', worktrees: [] }) - expect(folderRows(rows)).toHaveLength(1) - }) -}) - -describe('lane assignment', () => { - it('routes to the same PR lane as a worktree with no PR', () => { - const laneKey = getFolderWorkspaceLaneKey( - { folderWorkspace: makeFolderWorkspace(), projectGroup: GROUP }, - 'pr-status', - [] - ) - const noPrWorktreeLane = getPRLaneKey( - getPRGroupKey(worktree, new Map([[GROUPED_REPO.id, GROUPED_REPO]]), null) - ) - expect(laneKey).toBe(noPrWorktreeLane) - }) -}) - -describe('ordering', () => { - const ordered = [ - makeFolderWorkspace({ id: 'fw-low', name: 'Low', sortOrder: 1 }), - makeFolderWorkspace({ id: 'fw-high', name: 'High', sortOrder: 9 }) - ] - - // Both emitters are covered: grouped lanes and the separate flat-mode path. - for (const groupBy of ['workspace-status', 'none'] as WorktreeGroupBy[]) { - it(`orders by manualOrder then sortOrder under ${groupBy}`, () => { - const rows = buildSidebarRows({ groupBy, folderWorkspaces: ordered }) - expect(folderRows(rows).map((row) => row.folderWorkspace.id)).toEqual(['fw-high', 'fw-low']) - }) - - it(`prefers manualOrder over sortOrder under ${groupBy}`, () => { - const rows = buildSidebarRows({ - groupBy, - folderWorkspaces: [ - makeFolderWorkspace({ id: 'fw-low', name: 'Low', sortOrder: 9, manualOrder: 1 }), - makeFolderWorkspace({ id: 'fw-high', name: 'High', sortOrder: 1, manualOrder: 9 }) - ] - }) - expect(folderRows(rows).map((row) => row.folderWorkspace.id)).toEqual(['fw-high', 'fw-low']) - }) - } -}) - -describe('membership is decided once, not per mode', () => { - // Pair this negative case with a positive non-repo case so missing folder rows cannot pass vacuously. - it('renders nothing when the owning project group is not visible', () => { - const rows = buildSidebarRows({ groupBy: 'workspace-status', projectGroups: [] }) - expect(folderRows(rows)).toHaveLength(0) - }) - - it('renders under a non-repo mode when the owning group is visible', () => { - const rows = buildSidebarRows({ groupBy: 'workspace-status' }) - expect(folderRows(rows)).toHaveLength(1) - }) - - it('keeps archived folder workspaces behaving identically in every mode', () => { - const archived = [makeFolderWorkspace({ isArchived: true })] - const counts = ALL_GROUP_BY.map( - (groupBy) => folderRows(buildSidebarRows({ groupBy, folderWorkspaces: archived })).length - ) - // Keep archive handling consistent across modes; none currently filters folder workspaces by isArchived. - expect(counts).toEqual([1, 1, 1, 1]) - }) -}) - -describe('host bookkeeping for lanes containing folder workspaces', () => { - const SSH_HOST = 'ssh:target-1' as ExecutionHostId - - it('scopes a collapsed folder-only lane header to its host', () => { - const sshGroup: ProjectGroup = { ...GROUP, connectionId: 'target-1' } - const rows = buildRows( - 'workspace-status', - [], - new Map(), - null, - new Set(['workspace-status:in-progress']), - undefined, - undefined, - 'manual', - {}, - new Map(), - false, - undefined, - [sshGroup], - new Set(), - new Map(), - new Map(), - [], - undefined, - [makeFolderWorkspace()] - ) - const header = rows.find((row) => row.type === 'header') - expect(header).toBeDefined() - const counts = header && 'hostWorktreeCounts' in header ? header.hostWorktreeCounts : undefined - expect(counts).toBeDefined() - expect(counts?.get(SSH_HOST)).toBe(1) - }) - - it('gives a folder-only host an explicit empty id array', () => { - const sshGroup: ProjectGroup = { ...GROUP, connectionId: 'target-1' } - const rows = buildRows( - 'workspace-status', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - 'manual', - {}, - new Map(), - false, - undefined, - [sshGroup], - new Set(), - new Map(), - new Map(), - [], - undefined, - [makeFolderWorkspace()] - ) - const header = rows.find((row) => row.type === 'header') - const ids = header && 'hostWorktreeIds' in header ? header.hostWorktreeIds : undefined - // Publish the empty host key or host-section fallback will leak the global worktree IDs. - expect(ids?.has(SSH_HOST)).toBe(true) - expect(ids?.get(SSH_HOST)).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts deleted file mode 100644 index 66b3b3b7bbf..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildRows } from './build-rows.test-fixture' -import { PINNED_GROUP_KEY } from '../../../../../../shared/sidebar-list/group-keys' -import { getPRGroupKey } from './pr-group-key' -import { getGroupKeyForWorktree } from '../../../../../../shared/sidebar-list/worktree-group-keys' -import { repo, worktree, repoMap } from './worktree-list-groups-test-fixtures' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' - -describe('getPRGroupKey', () => { - it('puts merged PRs in the done group', () => { - const prCache = { - 'repo-1::feature/super-critical': { - data: { state: 'merged' } - } - } - - expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('done') - }) - - it('treats a matching suppressed PR as in progress', () => { - const prCache = { - 'repo-1::feature/super-critical': { - data: { number: 42, state: 'merged' } - } - } - - expect( - getPRGroupKey({ ...worktree, linkedPR: null, suppressedGitHubPR: 42 }, repoMap, prCache) - ).toBe('in-progress') - }) - - it('keeps a different PR in its review-status group', () => { - const prCache = { - 'repo-1::feature/super-critical': { - data: { number: 43, state: 'merged' } - } - } - - expect( - getPRGroupKey({ ...worktree, linkedPR: null, suppressedGitHubPR: 42 }, repoMap, prCache) - ).toBe('done') - }) - - it('prefers repo-scoped PR status over stale legacy path-scoped status', () => { - const prCache = { - '/tmp/orca::feature/super-critical': { - data: { state: 'closed' } - }, - 'repo-1::feature/super-critical': { - data: { state: 'merged' } - } - } - - expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('done') - }) - - it('falls back to legacy path-scoped PR status when no repo-scoped entry exists', () => { - const prCache = { - '/tmp/orca::feature/super-critical': { - data: { state: 'closed' } - } - } - - expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('closed') - }) - - it('uses SSH-scoped PR cache entries instead of local entries for SSH repos', () => { - const sshRepo = { ...repo, connectionId: 'ssh-1' } - const sshRepoMap = new Map([[sshRepo.id, sshRepo]]) - const prCache = { - 'repo-1::feature/super-critical': { - data: { state: 'merged' } - }, - 'ssh:ssh-1::repo-1::feature/super-critical': { - data: { state: 'closed' } - } - } - - expect(getPRGroupKey(worktree, sshRepoMap, prCache)).toBe('closed') - }) -}) - -describe('getGroupKeyForWorktree', () => { - it('returns the all group key for the ungrouped mode', () => { - expect(getGroupKeyForWorktree('none', worktree, repoMap, null)).toBe('all') - }) - - it('returns a workspace-status key only in status grouping mode', () => { - expect(getGroupKeyForWorktree('workspace-status', worktree, repoMap, null)).toBe( - 'workspace-status:in-progress' - ) - }) -}) - -describe('buildRows with pinned worktrees', () => { - const pinned = { ...worktree, id: 'wt-pinned', isPinned: true, displayName: 'pinned-feature' } - const unpinned1 = { ...worktree, id: 'wt-1', displayName: 'alpha' } - const unpinned2 = { ...worktree, id: 'wt-2', displayName: 'beta' } - - it('emits Pinned and All headers in groupBy none', () => { - const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) - expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', icon: { kind: 'pinned' } }) - expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) - expect(rows[2]).toMatchObject({ type: 'header', key: 'all', icon: { kind: 'all' }, count: 2 }) - expect(rows[2]).toMatchObject({ type: 'header', icon: { kind: 'all' } }) - }) - - it('uses worktree host ownership for pinned header host counts', () => { - const runtimePinned = { - ...pinned, - hostId: 'runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3' as const - } - - const rows = buildRows( - 'none', - [runtimePinned, unpinned1], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [runtimePinned.id, runtimePinned], - [unpinned1.id, unpinned1] - ]) - ) - const pinnedHeader = rows[0] - - expect(pinnedHeader).toMatchObject({ type: 'header', key: 'pinned' }) - expect(pinnedHeader.type === 'header' ? pinnedHeader.hostWorktreeCounts : undefined).toEqual( - new Map([['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 1]]) - ) - }) - - it('groups all worktrees under All in groupBy none', () => { - const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set()) - - expect(rows).toMatchObject([ - { type: 'header', key: 'all', icon: { kind: 'all' } }, - { type: 'item', worktree: { id: 'wt-1' } }, - { type: 'item', worktree: { id: 'wt-2' } } - ]) - }) - - it('moves pinned worktrees out of the All group', () => { - const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) - - expect(rows).toMatchObject([ - { type: 'header', key: 'pinned' }, - { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 2 }, - { type: 'item', worktree: { id: 'wt-1' } }, - { type: 'item', worktree: { id: 'wt-2' } } - ]) - }) - - it('duplicates pinned worktrees into All when the policy allows it', () => { - const rows = buildRows( - 'none', - [unpinned1, pinned, unpinned2], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - undefined, - false, - { showPinnedWorktreesInGroups: true } as never - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'pinned' }, - { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 3 }, - { type: 'item', sectionKey: 'all', worktree: { id: 'wt-1' } }, - { type: 'item', sectionKey: 'all', worktree: { id: 'wt-pinned' } }, - { type: 'item', sectionKey: 'all', worktree: { id: 'wt-2' } } - ]) - }) - - it('collapses the All group in groupBy none', () => { - const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all'])) - - expect(rows).toMatchObject([ - { type: 'header', key: 'pinned' }, - { type: 'item', worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'all', count: 2 } - ]) - }) - - it('emits status headers for unpinned matching worktrees in groupBy workspace-status', () => { - const rows = buildRows( - 'workspace-status', - [unpinned1, pinned, unpinned2], - repoMap, - null, - new Set() - ) - expect(rows[2]).toMatchObject({ - type: 'header', - key: 'workspace-status:in-progress', - label: 'In progress', - count: 2 - }) - expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) - }) - - it('duplicates pinned worktrees into status groups when the policy allows it', () => { - const rows = buildRows( - 'workspace-status', - [unpinned1, pinned], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - undefined, - false, - { showPinnedWorktreesInGroups: true } as never - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'pinned', count: 1 }, - { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } }, - { type: 'header', key: 'workspace-status:in-progress', count: 2 }, - { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-1' } }, - { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-pinned' } } - ]) - }) - - it('moves pinned items out of regular groups in pr-status mode', () => { - const rows = buildRows('pr-status', [unpinned1, pinned], repoMap, null, new Set()) - const pinnedHeader = rows.find((r) => r.type === 'header' && r.key === 'pinned') - expect(pinnedHeader).toBeDefined() - const prGroup = rows.filter((r) => r.type === 'header' && r.key.startsWith('pr:')) - for (const header of prGroup) { - if (header.type === 'header') { - expect(header.count).toBe(1) - } - } - }) - - it('omits empty pinned sections in groupBy workspace-status', () => { - const rows = buildRows('workspace-status', [unpinned1, unpinned2], repoMap, null, new Set()) - expect(rows[0]).toMatchObject({ - type: 'header', - key: 'workspace-status:in-progress', - label: 'In progress' - }) - expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) - }) - - it('collapses pinned group when in collapsedGroups', () => { - const rows = buildRows( - 'workspace-status', - [pinned, unpinned1], - repoMap, - null, - new Set(['pinned']) - ) - expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) - expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' }) - expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - }) - - it('omits status sections when all matching worktrees are pinned', () => { - const allPinned = { ...unpinned1, isPinned: true } - const rows = buildRows('workspace-status', [pinned, allPinned], repoMap, null, new Set()) - expect(rows.filter((r) => r.type === 'header')).toHaveLength(1) - expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 }) - }) - - it('preserves repo display casing in group labels', () => { - const lowercaseRepo = { ...repo, displayName: 'c15t' } - const rows = buildRows('repo', [worktree], new Map([[repo.id, lowercaseRepo]]), null, new Set()) - - expect(rows[0]).toMatchObject({ type: 'header', label: 'c15t' }) - }) - - it('groups folder-mode workspaces under their folder name', () => { - const folderRepo: Repo = { - ...repo, - id: 'folder-1', - path: '/tmp/design-assets', - displayName: 'design-assets', - kind: 'folder' - } - const folderWorktree: Worktree = { - ...worktree, - id: 'folder-1::/tmp/design-assets', - repoId: folderRepo.id, - path: folderRepo.path, - branch: '', - displayName: folderRepo.displayName, - isMainWorktree: true - } - const rows = buildRows( - 'repo', - [folderWorktree], - new Map([[folderRepo.id, folderRepo]]), - null, - new Set() - ) - - expect(rows[0]).toMatchObject({ - type: 'header', - key: 'repo:folder-1', - label: 'design-assets', - repo: folderRepo - }) - expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: folderWorktree.id } }) - }) - - it('emits assigned workspace statuses as sections in groupBy workspace-status', () => { - const review = { ...worktree, id: 'wt-review', workspaceStatus: 'in-review' as const } - const rows = buildRows('workspace-status', [review], repoMap, null, new Set()) - - expect( - rows.filter((r) => r.type === 'header').map((r) => ({ key: r.key, label: r.label })) - ).toEqual([{ key: 'workspace-status:in-review', label: 'In review' }]) - }) - - it('uses customized workspace status labels and order', () => { - const customStatuses = [ - { id: 'blocked', label: 'Blocked' }, - { id: 'todo', label: 'Ready' }, - { id: 'in-progress', label: 'Doing' } - ] - const blocked = { ...worktree, id: 'wt-blocked', workspaceStatus: 'blocked' } - const doing = { ...worktree, id: 'wt-doing', workspaceStatus: 'in-progress' } - const rows = buildRows( - 'workspace-status', - [doing, blocked], - repoMap, - null, - new Set(), - undefined, - customStatuses - ) - - expect( - rows.filter((r) => r.type === 'header').map((r) => ({ key: r.key, label: r.label })) - ).toEqual([ - { key: 'workspace-status:blocked', label: 'Blocked' }, - { key: 'workspace-status:in-progress', label: 'Doing' } - ]) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-section-project-headers.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-section-project-headers.test.ts deleted file mode 100644 index 81c13ed1bc9..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-section-project-headers.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { - addHostSectionRows, - type HostSectionOption, - type HostSectionRow -} from '../../../../../../shared/sidebar-list/host-section-rows' -import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' -import { getProjectHeaderHostRepos } from '../../../../../../shared/sidebar-list/host-labels' -import { createHostScopedRepoOf } from '../../../../../../shared/sidebar-list/sidebar-feed-index' -import { - getRepoExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' -import { - repo, - remoteRepo, - worktree, - remoteWorktree, - project, - projectHostSetups -} from './worktree-list-groups-test-fixtures' - -const hosts: HostSectionOption[] = [ - { id: 'local', kind: 'local', label: 'Local Mac', detail: '', health: 'local' }, - { id: 'ssh:gpu-vm', kind: 'ssh', label: 'Builder', detail: '', health: 'available' } -] - -const otherRepo: Repo = { ...repo, id: 'repo-other', path: '/tmp/other', displayName: 'other' } -const otherWorktree: Worktree = { - ...worktree, - id: 'wt-other', - repoId: otherRepo.id, - path: '/tmp/other-feature' -} - -function hostSectionRows(collapsedGroups: Set): HostSectionRow[] { - const worktrees = [worktree, otherWorktree, remoteWorktree] - const rows = planSidebarRows({ - groupBy: 'repo', - sortBy: 'smart', - worktrees, - repoMap: new Map([repo, otherRepo, remoteRepo].map((entry) => [entry.id, entry])), - prGroupOf: () => 'in-progress', - workspaceStatuses: cloneDefaultWorkspaceStatuses(), - projectOrderBy: 'manual', - repoOrder: new Map([ - [repo.id, 0], - [otherRepo.id, 1], - [remoteRepo.id, 2] - ]), - lineageById: {}, - worktreeMap: new Map(worktrees.map((row) => [row.id, row])), - nestLineage: false, - projectGroups: [], - placeholderRepoIds: new Set(), - importedWorktreesByRepo: new Map(), - newExternalWorktreesInboxByRepo: new Map(), - pendingCreations: [], - projectGrouping: { projects: [project], projectHostSetups }, - folderWorkspaces: [], - hostLabelById: new Map(hosts.map((host) => [host.id, host.label])), - defaultHostId: 'local', - pinnedDisplayPolicy: 'single-location', - filterRepoIds: [] - }).render(collapsedGroups) - return addHostSectionRows({ - rows, - hostOptions: hosts, - workspaceHostScope: 'all', - visibleWorkspaceHostIds: hosts.map((host) => host.id), - defaultHostId: 'local', - localHostLabel: 'Local Mac', - preferProjectGrouping: true - }) -} - -function describeRow(row: HostSectionRow): string { - switch (row.type) { - case 'host-header': - return `${row.key} (${row.count})` - case 'header': - return `${row.key} @${row.repo?.id} (${row.count})` - case 'item': - return row.worktree.id - case 'folder-workspace': - case 'imported-worktrees-card': - case 'new-external-worktrees-inbox': - case 'pending-creation': - return row.type - } -} - -describe('a project checked out on two hosts, in host sections', () => { - it("heads each host's rows with that host's own project header", () => { - expect(hostSectionRows(new Set()).map(describeRow)).toEqual([ - 'host:local (2)', - `project:${project.id} @${repo.id} (1)`, - worktree.id, - `repo:${otherRepo.id} @${otherRepo.id} (1)`, - otherWorktree.id, - 'host:ssh:gpu-vm (1)', - `project:${project.id} @${remoteRepo.id} (1)`, - remoteWorktree.id - ]) - }) - - it('keeps a collapsed copy in each host section with that host’s count', () => { - const rows = hostSectionRows(new Set([`project:${project.id}`])) - - expect(rows.map(describeRow)).toEqual([ - 'host:local (2)', - `project:${project.id} @${repo.id} (1)`, - `repo:${otherRepo.id} @${otherRepo.id} (1)`, - otherWorktree.id, - 'host:ssh:gpu-vm (1)', - `project:${project.id} @${remoteRepo.id} (1)` - ]) - expect(rows.flatMap((row) => (row.type === 'header' && row.repo ? [row.hostId] : []))).toEqual([ - 'local', - 'local', - 'ssh:gpu-vm' - ]) - }) -}) - -describe('one repo id registered on two hosts, in host sections', () => { - const localTwin: Repo = { ...repo, id: 'repo-twin' } - const sshTwin: Repo = { ...localTwin, path: '/home/alice/twin', connectionId: 'gpu-vm' } - const runtimeHost: HostSectionOption = { - id: 'runtime:env', - kind: 'runtime', - label: 'Server', - detail: '', - health: 'available' - } - const runtimeRepo: Repo = { ...repo, id: 'repo-runtime', executionHostId: 'runtime:env' } - - function twinSectionRows( - worktrees: Worktree[], - repoMapOrder: Repo[], - visibleWorkspaceHostIds: ExecutionHostId[] - ): HostSectionRow[] { - const repoMap = new Map(repoMapOrder.map((entry) => [entry.id, entry])) - const rows = planSidebarRows({ - groupBy: 'repo', - sortBy: 'smart', - worktrees, - repoMap, - repoOfWorktree: createHostScopedRepoOf([localTwin, sshTwin, runtimeRepo], repoMap), - prGroupOf: () => 'in-progress', - workspaceStatuses: cloneDefaultWorkspaceStatuses(), - projectOrderBy: 'manual', - lineageById: {}, - worktreeMap: new Map(worktrees.map((row) => [row.id, row])), - nestLineage: false, - projectGroups: [], - placeholderRepoIds: new Set(), - importedWorktreesByRepo: new Map(), - newExternalWorktreesInboxByRepo: new Map(), - pendingCreations: [], - folderWorkspaces: [], - defaultHostId: 'local', - pinnedDisplayPolicy: 'single-location', - filterRepoIds: [] - }).render(new Set()) - return addHostSectionRows({ - rows, - hostOptions: [...hosts, runtimeHost], - workspaceHostScope: 'all', - visibleWorkspaceHostIds, - defaultHostId: 'local', - localHostLabel: 'Local Mac', - preferProjectGrouping: true - }) - } - - function headerHosts(rows: HostSectionRow[]): [string | undefined, string | undefined][] { - return rows.flatMap((row) => - row.type === 'header' && row.repo ? [[row.hostId, getRepoExecutionHostId(row.repo)]] : [] - ) - } - - const sshWorktree: Worktree = { - ...worktree, - id: 'wt-twin-ssh', - repoId: sshTwin.id, - hostId: 'ssh:gpu-vm' - } - const runtimeWorktree: Worktree = { ...worktree, id: 'wt-runtime', repoId: runtimeRepo.id } - - it.each<[string, ExecutionHostId[]]>([ - ['an empty second host', ['ssh:gpu-vm', 'runtime:env']], - ['one visible host', ['ssh:gpu-vm']] - ])('names the shown host’s registration when %s leaves no host sections', (_, visible) => { - const rows = twinSectionRows([sshWorktree], [localTwin, runtimeRepo], visible) - - expect(rows.some((row) => row.type === 'host-header')).toBe(false) - expect(headerHosts(rows)).toEqual([['ssh:gpu-vm', 'ssh:gpu-vm']]) - }) - - it('does not open a section for a hidden host that only anchors the header', () => { - const rows = twinSectionRows( - [sshWorktree, runtimeWorktree], - [localTwin, runtimeRepo], - ['ssh:gpu-vm', 'runtime:env'] - ) - - expect(rows.filter((row) => row.type === 'host-header').map((row) => row.hostId)).toEqual([ - 'ssh:gpu-vm', - 'runtime:env' - ]) - expect(headerHosts(rows)).toEqual([ - ['ssh:gpu-vm', 'ssh:gpu-vm'], - ['runtime:env', 'runtime:env'] - ]) - }) -}) - -describe('a lineage child from another project on another host', () => { - it("never heads that host's copy with the child's project", () => { - const childProject: Repo = { ...remoteRepo, id: 'repo-child-project' } - const child: Worktree = { ...remoteWorktree, id: 'wt-child', repoId: childProject.id } - - expect( - getProjectHeaderHostRepos({ - group: { label: 'orca', items: [worktree, child], repo, repoIds: new Set([repo.id]) }, - repoMap: new Map([repo, childProject].map((entry) => [entry.id, entry])), - repoOf: (row) => (row.id === child.id ? childProject : repo), - noticeRepoOf: () => [], - hasPendingCreation: () => false, - defaultHostId: 'local' - }) - ).toEqual(new Map([['local', repo]])) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-section-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-section-rows.test.ts deleted file mode 100644 index dec5a6bd9de..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-section-rows.test.ts +++ /dev/null @@ -1,866 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { PINNED_GROUP_KEY } from '../../../../../../shared/sidebar-list/group-keys' -import type { Row } from '../../../../../../shared/sidebar-list/row-types' -import { - addHostSectionRows, - type HostSectionRow -} from '../../../../../../shared/sidebar-list/host-section-rows' - -function sectionRows( - args: Omit[0], 'localHostLabel'> -): HostSectionRow[] { - return addHostSectionRows({ ...args, localHostLabel: 'Local Mac' }) -} - -function repo(id: string, connectionId?: string | null): Repo { - return { - id, - path: `/${id}`, - displayName: id, - badgeColor: '#000000', - addedAt: 0, - connectionId - } -} - -function worktree(id: string, repoId: string): Worktree { - return { - id, - repoId, - path: `/${repoId}/${id}`, - branch: `refs/heads/${id}`, - head: 'abc123', - isBare: false, - isMainWorktree: false, - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - comment: '', - isUnread: false, - isPinned: false, - displayName: id, - sortOrder: 0, - lastActivityAt: 0 - } -} - -function header(key: string, label = key): Extract { - return { - type: 'header', - key, - label, - icon: { kind: 'project' }, - count: 1 - } -} - -function pinnedHeader( - counts: ReadonlyMap<'local' | 'ssh:ssh-1', number>, - idsByHost?: ReadonlyMap<'local' | 'ssh:ssh-1', readonly string[]> -): Extract { - return { - ...header(PINNED_GROUP_KEY, 'Pinned'), - count: Array.from(counts.values()).reduce((sum, count) => sum + count, 0), - hostWorktreeCounts: counts, - hostWorktreeIds: idsByHost, - worktreeIds: idsByHost ? Array.from(idsByHost.values()).flat() : undefined - } -} - -function repoHeader(project: Repo): Extract { - return { - ...header(`repo:${project.id}`, project.displayName), - repo: project - } -} - -function item(id: string, project: Repo): Extract { - const sectionKey = `repo:${project.id}` - return { - type: 'item', - rowKey: `${sectionKey}:${id}`, - sectionKey, - worktree: worktree(id, project.id), - repo: project, - depth: 0, - groupDepth: 0, - isLastLineageChild: true, - lineageChildCount: 0 - } -} - -function pinnedItem(id: string, project: Repo, sectionKey: string): Extract { - const row = item(id, project) - row.worktree.isPinned = true - row.rowKey = `${sectionKey}:${id}` - row.sectionKey = sectionKey - return row -} - -function folderWorkspaceRow( - connectionId: string | null -): Extract { - const projectGroup: ProjectGroup = { - id: 'group-1', - name: 'Remote folder', - parentPath: '/srv/project', - connectionId, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const folderWorkspace: FolderWorkspace = { - id: 'folder-1', - projectGroupId: projectGroup.id, - name: 'Folder workspace', - folderPath: '/srv/project', - connectionId, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } - return { - type: 'folder-workspace', - key: 'folder-workspace:folder-1', - folderWorkspace, - projectGroup, - depth: 0, - groupDepth: 0 - } -} - -function rowKey(row: HostSectionRow): string { - return row.type === 'item' ? row.worktree.id : row.key -} - -describe('addHostSectionRows', () => { - it('does not add host headers for a specific host scope', () => { - const local = repo('local') - const rows = [repoHeader(local), item('local-wt', local)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'local', - defaultHostId: 'local' - }) - - expect(sectioned).toHaveLength(2) - expect(sectioned).toEqual(rows) - }) - - it('does not add host headers when only the local host exists', () => { - const local = repo('local') - const rows = [repoHeader(local), item('local-wt', local)] - - expect( - sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - ).toEqual(rows) - }) - - it('groups rows under host headers in all-host scope', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'repo:local', - 'local-wt', - 'host:ssh:ssh-1', - 'repo:ssh', - 'ssh-wt' - ]) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { label: 'Local Mac', count: 1 }, - { label: 'Builder', count: 1 } - ]) - }) - - it('keeps project grouping outermost in the default Projects view', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local', - preferProjectGrouping: true - }) - - expect(sectioned).toEqual(rows) - }) - - it('keeps host headers for a custom multi-host visibility filter', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - visibleWorkspaceHostIds: ['local', 'ssh:ssh-1'], - defaultHostId: 'local', - preferProjectGrouping: true - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'repo:local', - 'local-wt', - 'host:ssh:ssh-1', - 'repo:ssh', - 'ssh-wt' - ]) - }) - - it('keeps non-repo group headers with the following host-owned rows', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [header('all'), item('local-wt', local), header('done'), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'all', - 'local-wt', - 'host:ssh:ssh-1', - 'done', - 'ssh-wt' - ]) - }) - - it('copies global pinned and all headers into each mixed-host section without duplicating pins', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [ - pinnedHeader( - new Map([ - ['local', 1], - ['ssh:ssh-1', 1] - ]) - ), - pinnedItem('local-pinned', local, PINNED_GROUP_KEY), - pinnedItem('ssh-pinned', ssh, PINNED_GROUP_KEY), - header('all', 'All'), - item('local-normal', local), - item('ssh-normal', ssh) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned.map((row) => (row.type === 'item' ? row.rowKey : row.key))).toEqual([ - 'host:local', - 'pinned', - 'pinned:local-pinned', - 'all', - 'repo:local:local-normal', - 'host:ssh:ssh-1', - 'pinned', - 'pinned:ssh-pinned', - 'all', - 'repo:ssh:ssh-normal' - ]) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { label: 'Local Mac', count: 2 }, - { label: 'Builder', count: 2 } - ]) - expect( - sectioned.filter((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY) - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ count: 1, hostId: 'local' }), - expect.objectContaining({ count: 1, hostId: 'ssh:ssh-1' }) - ]) - ) - }) - - it('keeps collapsed pinned rows attributable to their owning hosts', () => { - const rows = [ - pinnedHeader( - new Map([ - ['local', 1], - ['ssh:ssh-1', 2] - ]) - ) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local', - collapsedHostKeys: new Set(['host:ssh:ssh-1']) - }) - - expect(sectioned.map(rowKey)).toEqual(['host:local', 'pinned', 'host:ssh:ssh-1']) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { hostId: 'local', collapsed: false, count: 1 }, - { hostId: 'ssh:ssh-1', collapsed: true, count: 2 } - ]) - expect(sectioned.filter((row) => row.type === 'header')).toMatchObject([ - { key: PINNED_GROUP_KEY, count: 1, hostId: 'local' } - ]) - }) - - it('keeps a pinned row with its explicit worktree host', () => { - const local = repo('local') - const pinned = pinnedItem('ssh-pinned', local, PINNED_GROUP_KEY) - pinned.worktree.hostId = 'ssh:ssh-1' - const rows = [ - pinnedHeader(new Map([['ssh:ssh-1', 1]]), new Map([['ssh:ssh-1', ['ssh-pinned']]])), - pinned, - repoHeader(local), - item('local-wt', local) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'repo:local', - 'local-wt', - 'host:ssh:ssh-1', - PINNED_GROUP_KEY, - 'ssh-pinned' - ]) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { hostId: 'local', count: 1 }, - { hostId: 'ssh:ssh-1', count: 1 } - ]) - }) - - it('does not double-count a collapsed pinned header and its natural duplicate row', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [ - pinnedHeader(new Map([['local', 1]]), new Map([['local', ['wt-1']]])), - { ...header('all', 'All'), count: 1, worktreeIds: ['wt-1'] }, - item('wt-1', local), - repoHeader(ssh), - item('ssh-wt', ssh) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect( - sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') - ).toMatchObject({ count: 1 }) - }) - - it('localizes collapsed natural headers even when the hidden rows are owned by one host', () => { - const ssh = repo('ssh', 'ssh-1') - const localHostId = 'local' as const - const rows = [ - { - ...header('all', 'All'), - count: 1, - hostWorktreeCounts: new Map([[localHostId, 1]]), - hostWorktreeIds: new Map([[localHostId, ['local-wt']]]), - worktreeIds: ['local-wt'] - }, - repoHeader(ssh), - item('ssh-wt', ssh) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'all', - 'host:ssh:ssh-1', - 'repo:ssh', - 'ssh-wt' - ]) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { hostId: 'local', count: 1 }, - { hostId: 'ssh:ssh-1', count: 1 } - ]) - expect(sectioned.filter((row) => row.type === 'header' && row.key === 'all')).toMatchObject([ - { hostId: 'local', count: 1, worktreeIds: ['local-wt'] } - ]) - }) - - it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => { - const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } - const runtimeOwned: Repo = { ...repo('remote-project'), executionHostId: 'runtime:env-2' } - const rows = [ - repoHeader(localOwned), - item('local-wt', localOwned), - repoHeader(runtimeOwned), - item('remote-wt', runtimeOwned) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { - id: 'runtime:env-1', - kind: 'runtime', - label: 'env-1', - detail: 'Orca server', - health: 'available' - }, - { - id: 'runtime:env-2', - kind: 'runtime', - label: 'env-2', - detail: 'Orca server', - health: 'available' - } - ], - workspaceHostScope: 'all', - defaultHostId: 'runtime:env-1' - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'repo:local-project', - 'local-wt', - 'host:runtime:env-2', - 'repo:remote-project', - 'remote-wt' - ]) - }) - - it('groups SSH folder workspace rows under their connection host', () => { - const local = repo('local') - const rows: Row[] = [repoHeader(local), item('local-wt', local), folderWorkspaceRow('ssh-1')] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'repo:local', - 'local-wt', - 'host:ssh:ssh-1', - 'folder-workspace:folder-1' - ]) - }) - - it('carries the SSH connection status through to the host header row', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { - id: 'ssh:ssh-1', - kind: 'ssh', - label: 'Builder', - detail: 'SSH', - health: 'error', - connectionStatus: 'auth-failed' - } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect( - sectioned.find((row) => row.type === 'host-header' && row.hostId === 'ssh:ssh-1') - ).toMatchObject({ - health: 'error', - connectionStatus: 'auth-failed', - collapsed: false - }) - }) - - it('uses the focused runtime as the owner for non-SSH repos', () => { - const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } - const project = repo('runtime-project') - const rows = [ - repoHeader(localOwned), - item('local-wt', localOwned), - repoHeader(project), - item('runtime-wt', project) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { - id: 'runtime:env-1', - kind: 'runtime', - label: 'env-1', - detail: 'Orca server', - health: 'available' - } - ], - workspaceHostScope: 'all', - defaultHostId: 'runtime:env-1' - }) - - expect( - sectioned.find((row) => row.type === 'host-header' && row.hostId === 'runtime:env-1') - ).toMatchObject({ - key: 'host:runtime:env-1', - label: 'env-1' - }) - }) - - it('passes host kind, blocked compatibility and reason through to the header row', () => { - const localOwned: Repo = { ...repo('local-project'), executionHostId: 'local' } - const project = repo('runtime-project') - const rows = [ - repoHeader(localOwned), - item('local-wt', localOwned), - repoHeader(project), - item('runtime-wt', project) - ] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { - id: 'runtime:env-1', - kind: 'runtime', - label: 'env-1', - detail: 'Orca server', - health: 'blocked', - compatibility: { - kind: 'blocked', - reason: 'server-too-old', - clientProtocolVersion: 5, - serverProtocolVersion: 1, - requiredServerProtocolVersion: 4 - }, - blockedReason: 'incompatible' - } - ], - workspaceHostScope: 'all', - defaultHostId: 'runtime:env-1' - }) - - expect( - sectioned.find((row) => row.type === 'host-header' && row.hostId === 'runtime:env-1') - ).toMatchObject({ - kind: 'runtime', - health: 'blocked', - compatibility: { kind: 'blocked', reason: 'server-too-old' }, - blockedReason: 'incompatible' - }) - }) - - it('suppresses host headers when only one host has visible workspaces', () => { - const local = repo('local') - const rows = [repoHeader(local), item('local-wt', local)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'disconnected' }, - { - id: 'runtime:env-1', - kind: 'runtime', - label: 'env-1', - detail: 'Orca server', - health: 'available' - } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect(sectioned).toEqual(rows) - }) - - it('counts a collapsed repo group via its header count instead of zero', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const collapsedSshHeader = { ...repoHeader(ssh), count: 9 } - const rows = [repoHeader(local), item('local-wt', local), collapsedSshHeader] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local' - }) - - expect( - sectioned.find((row) => row.type === 'host-header' && row.hostId === 'ssh:ssh-1') - ).toMatchObject({ count: 9 }) - expect( - sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local') - ).toMatchObject({ count: 1 }) - }) - - it('keeps a collapsed host header but hides its rows', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local', - collapsedHostKeys: new Set(['host:ssh:ssh-1']) - }) - - expect(sectioned.map(rowKey)).toEqual([ - 'host:local', - 'repo:local', - 'local-wt', - 'host:ssh:ssh-1' - ]) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { hostId: 'local', collapsed: false }, - { hostId: 'ssh:ssh-1', collapsed: true, count: 1 } - ]) - }) - - it('can temporarily collapse every host without mutating persisted collapse keys', () => { - const local = repo('local') - const ssh = repo('ssh', 'ssh-1') - const rows = [repoHeader(local), item('local-wt', local), repoHeader(ssh), item('ssh-wt', ssh)] - - const sectioned = sectionRows({ - rows, - hostOptions: [ - { - id: 'local', - kind: 'local', - label: 'Local Mac', - detail: 'This computer', - health: 'local' - }, - { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' } - ], - workspaceHostScope: 'all', - defaultHostId: 'local', - collapsedHostKeys: new Set(), - forceCollapseHosts: true - }) - - expect(sectioned.map(rowKey)).toEqual(['host:local', 'host:ssh:ssh-1']) - expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([ - { hostId: 'local', collapsed: true, count: 1 }, - { hostId: 'ssh:ssh-1', collapsed: true, count: 1 } - ]) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-imported-worktrees.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-imported-worktrees.test.ts deleted file mode 100644 index ec123cff24a..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-imported-worktrees.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildRows } from './build-rows.test-fixture' -import { PINNED_GROUP_KEY } from '../../../../../../shared/sidebar-list/group-keys' -import { repo, worktree, repoMap, makeDetectedWorktree } from './worktree-list-groups-test-fixtures' -import type { Repo } from '../../../../../../shared/repo-types' - -describe('buildRows with pinned worktrees', () => { - it('emits an imported worktrees card at the top of repo-group rows', () => { - const hidden = [ - makeDetectedWorktree({ id: 'hidden-1', displayName: 'payments-refactor' }), - makeDetectedWorktree({ id: 'hidden-2', displayName: 'auth-cache-debug' }), - makeDetectedWorktree({ id: 'hidden-3', displayName: 'legacy-oauth-fix' }) - ] - const rows = buildRows( - 'repo', - [worktree], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [], - new Set(), - new Map([[repo.id, { repo, hiddenWorktrees: hidden }]]) - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-1' }, - { - type: 'imported-worktrees-card', - key: 'imported-worktrees-card:repo-group:repo-1', - placement: 'repo-group', - repo: { id: 'repo-1' }, - hiddenWorktrees: [{ id: 'hidden-1' }, { id: 'hidden-2' }, { id: 'hidden-3' }] - }, - { type: 'item', worktree: { id: 'wt-1' } } - ]) - }) - - it('suppresses the repo-group imported worktrees card when the repo group is collapsed', () => { - const rows = buildRows( - 'repo', - [worktree], - repoMap, - null, - new Set(['repo:repo-1']), - undefined, - undefined, - undefined, - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [], - new Set(), - new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toMatchObject([{ type: 'header', key: 'repo:repo-1' }]) - }) - - it('emits a repo header and imported worktrees card when no visible worktree rows remain', () => { - const rows = buildRows( - 'repo', - [], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(), - false, - undefined, - [], - new Set(), - new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-1' }, - { - type: 'imported-worktrees-card', - key: 'imported-worktrees-card:repo-group:repo-1', - placement: 'repo-group' - } - ]) - }) - - it('emits an empty ungrouped repo placeholder before imported cards are merged', () => { - const rows = buildRows( - 'repo', - [], - repoMap, - null, - new Set(), - new Map([[repo.id, 0]]), - undefined, - 'manual', - {}, - new Map(), - false, - undefined, - [], - new Set([repo.id]), - new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-1', label: 'orca' }, - { - type: 'imported-worktrees-card', - key: 'imported-worktrees-card:repo-group:repo-1', - placement: 'repo-group' - } - ]) - }) - - it('skips stale empty placeholder repo ids that are absent from repoMap', () => { - const rows = buildRows( - 'repo', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(), - false, - undefined, - [], - new Set([repo.id]) - ) - - expect(rows).toEqual([]) - }) - - it('shows one line for every project at the end of a list not grouped by project', () => { - const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } - const pinned = { ...worktree, id: 'repo-1-pinned', isPinned: true } - const review = { - ...worktree, - id: 'repo-2-review', - repoId: repoTwo.id, - workspaceStatus: 'in-review' as const - } - const hiddenOne = makeDetectedWorktree({ id: 'hidden-one' }) - const hiddenTwo = makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id }) - for (const [groupBy, collapsed] of [ - ['workspace-status', new Set()], - ['workspace-status', new Set([PINNED_GROUP_KEY])], - ['none', new Set(['all'])] - ] as const) { - const rows = buildRows( - groupBy, - [pinned, review], - new Map([ - [repo.id, repo], - [repoTwo.id, repoTwo] - ]), - null, - new Set(collapsed), - undefined, - undefined, - undefined, - {}, - new Map([ - [pinned.id, pinned], - [review.id, review] - ]), - false, - undefined, - [], - new Set(), - new Map([ - [repo.id, { repo, hiddenWorktrees: [hiddenOne] }], - [repoTwo.id, { repo: repoTwo, hiddenWorktrees: [hiddenTwo] }] - ]) - ) - - expect(rows.filter((row) => row.type === 'imported-worktrees-card')).toHaveLength(1) - expect(rows.at(-1)).toMatchObject({ - type: 'imported-worktrees-card', - placement: 'all-projects', - projects: [{ hiddenWorktrees: [hiddenOne] }, { hiddenWorktrees: [hiddenTwo] }] - }) - expect(rows.at(-1)).not.toHaveProperty('hostContextLabel') - } - }) - - it('emits a new external worktrees inbox row before repo worktree rows', () => { - const inboxWorktrees = [ - makeDetectedWorktree({ id: 'inbox-1', displayName: 'payments-refactor' }), - makeDetectedWorktree({ id: 'inbox-2', displayName: 'auth-cache-debug' }) - ] - const rows = buildRows( - 'repo', - [worktree], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [], - new Set(), - new Map(), - new Map([[repo.id, { repo, inboxWorktrees }]]) - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-1' }, - { - type: 'new-external-worktrees-inbox', - key: 'new-external-worktrees-inbox:repo-1', - repo: { id: 'repo-1' }, - inboxWorktrees: [{ id: 'inbox-1' }, { id: 'inbox-2' }] - }, - { type: 'item', worktree: { id: 'wt-1' } } - ]) - }) - - it('suppresses the new external worktrees inbox row when the repo group is collapsed', () => { - const rows = buildRows( - 'repo', - [worktree], - repoMap, - null, - new Set(['repo:repo-1']), - undefined, - undefined, - undefined, - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [], - new Set(), - new Map(), - new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toMatchObject([{ type: 'header', key: 'repo:repo-1' }]) - }) - - it('emits a repo header and inbox row when no visible worktree rows remain', () => { - const rows = buildRows( - 'repo', - [], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(), - false, - undefined, - [], - new Set(), - new Map(), - new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-1' }, - { - type: 'new-external-worktrees-inbox', - key: 'new-external-worktrees-inbox:repo-1' - } - ]) - }) - - it('keeps the inbox group when the repo only has a pinned worktree', () => { - const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } - const rows = buildRows( - 'repo', - [pinnedWorktree], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[pinnedWorktree.id, pinnedWorktree]]), - false, - undefined, - [], - new Set(), - new Map(), - new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'item', - sectionKey: PINNED_GROUP_KEY, - worktree: expect.objectContaining({ id: pinnedWorktree.id }) - }), - expect.objectContaining({ type: 'header', key: `repo:${repo.id}` }), - expect.objectContaining({ - type: 'new-external-worktrees-inbox', - key: `new-external-worktrees-inbox:${repo.id}` - }) - ]) - ) - expect( - rows.some( - (row) => - row.type === 'item' && - row.sectionKey === `repo:${repo.id}` && - row.worktree.id === pinnedWorktree.id - ) - ).toBe(false) - }) - - it('does not emit new external worktrees inbox rows outside repo grouping', () => { - const rows = buildRows( - 'workspace-status', - [worktree], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [], - new Set(), - new Map(), - new Map([[repo.id, { repo, inboxWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows.some((row) => row.type === 'new-external-worktrees-inbox')).toBe(false) - }) - - it('emits imported worktree cards in repo groups when visible rows are pinned', () => { - const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' } - const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true } - const pinnedTwo = { - ...worktree, - id: 'repo-2-pinned', - repoId: repoTwo.id, - isPinned: true, - displayName: 'auth-main' - } - const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true } - const rows = buildRows( - 'repo', - [pinnedOneA, pinnedTwo, pinnedOneB], - new Map([ - [repo.id, repo], - [repoTwo.id, repoTwo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [pinnedOneA.id, pinnedOneA], - [pinnedTwo.id, pinnedTwo], - [pinnedOneB.id, pinnedOneB] - ]), - false, - undefined, - [], - new Set(), - new Map([ - [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }], - [ - repoTwo.id, - { - repo: repoTwo, - hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })] - } - ] - ]) - ) - - expect(rows.filter((row) => row.type === 'imported-worktrees-card')).toMatchObject([ - { - key: 'imported-worktrees-card:repo-group:repo-1', - placement: 'repo-group' - }, - { - key: 'imported-worktrees-card:repo-group:repo-2', - placement: 'repo-group' - } - ]) - expect( - rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) - ).toEqual([]) - expect(rows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'item', - sectionKey: PINNED_GROUP_KEY, - worktree: expect.objectContaining({ id: 'repo-1-pinned-a' }) - }), - expect.objectContaining({ - type: 'item', - sectionKey: PINNED_GROUP_KEY, - worktree: expect.objectContaining({ id: 'repo-1-pinned-b' }) - }), - expect.objectContaining({ - type: 'item', - sectionKey: PINNED_GROUP_KEY, - worktree: expect.objectContaining({ id: 'repo-2-pinned' }) - }) - ]) - ) - }) - - it('duplicates pinned worktrees into repo groups when the policy allows it', () => { - const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } - const rows = buildRows( - 'repo', - [pinnedWorktree], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[pinnedWorktree.id, pinnedWorktree]]), - false, - { showPinnedWorktreesInGroups: true } as never, - [], - new Set(), - new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'item', - sectionKey: PINNED_GROUP_KEY, - worktree: expect.objectContaining({ id: 'wt-pinned' }) - }), - expect.objectContaining({ - type: 'item', - sectionKey: `repo:${repo.id}`, - worktree: expect.objectContaining({ id: 'wt-pinned' }) - }) - ]) - ) - }) - - it('suppresses pinned imported worktree fallback when the repo has visible unpinned rows', () => { - const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } - const rows = buildRows( - 'repo', - [pinnedWorktree, worktree], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [pinnedWorktree.id, pinnedWorktree], - [worktree.id, worktree] - ]), - false, - undefined, - [], - new Set(), - new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows.filter((row) => row.type === 'imported-worktrees-card')).toMatchObject([ - { placement: 'repo-group' } - ]) - }) - - it('keeps repo imported worktree cards visible when Pinned is collapsed', () => { - const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true } - const rows = buildRows( - 'repo', - [pinnedWorktree], - repoMap, - null, - new Set(['pinned']), - undefined, - undefined, - undefined, - {}, - new Map([[pinnedWorktree.id, pinnedWorktree]]), - false, - undefined, - [], - new Set(), - new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]]) - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'pinned' }, - { type: 'header', key: 'repo:repo-1' }, - { type: 'imported-worktrees-card', placement: 'repo-group' } - ]) - expect( - rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY) - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-lineage-nesting.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-lineage-nesting.test.ts index 85d77562f37..00118d07ea6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-lineage-nesting.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-lineage-nesting.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildRows } from './build-rows.test-fixture' -import { - getLineageGroupKey, - getWorktreeLineageGroupKey -} from '../../../../../../shared/sidebar-list/group-keys' -import { - getLineageRenderInfo, - getWorktreeLineageAncestors -} from '../../../../../../shared/sidebar-list/worktree-lineage-projection' +import { getWorktreeLineageGroupKey } from '../../../../../../shared/sidebar-list/group-keys' + import { worktree, repoMap } from './worktree-list-groups-test-fixtures' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../../../shared/worktree/types' @@ -31,12 +25,7 @@ describe('buildRows workspace lineage nesting', () => { instanceId: 'child-instance', displayName: 'worker' } - const grandchild: Worktree = { - ...worktree, - id: 'wt-grandchild', - instanceId: 'grandchild-instance', - displayName: 'nested-worker' - } + const lineage: WorktreeLineage = { worktreeId: child.id, worktreeInstanceId: 'child-instance', @@ -46,68 +35,6 @@ describe('buildRows workspace lineage nesting', () => { capture: { source: 'terminal-context', confidence: 'inferred' }, createdAt: 1 } - const grandchildLineage: WorktreeLineage = { - worktreeId: grandchild.id, - worktreeInstanceId: 'grandchild-instance', - parentWorktreeId: child.id, - parentWorktreeInstanceId: 'child-instance', - origin: 'cli', - capture: { source: 'terminal-context', confidence: 'inferred' }, - createdAt: 1 - } - - it('keeps lineage flat when nesting is off', () => { - const rows = buildRows( - 'none', - [child, parent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage }, - new Map([ - [parent.id, parent], - [child.id, child] - ]) - ) - - const items = rows.filter((row) => row.type === 'item') - expect(items[0]).toMatchObject({ type: 'item', worktree: { id: child.id } }) - expect(items[0]).not.toHaveProperty('parentLabel') - expect(items[1]).toMatchObject({ - type: 'item', - worktree: { id: parent.id } - }) - }) - - it('places children directly under their parent when nesting is on', () => { - const rows = buildRows( - 'none', - [child, parent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage }, - new Map([ - [parent.id, parent], - [child.id, child] - ]), - true - ) - - const items = rows.filter((row) => row.type === 'item') - expect(items[0]).toMatchObject({ type: 'item', worktree: { id: parent.id } }) - expect(items[1]).toMatchObject({ - type: 'item', - worktree: { id: child.id }, - depth: 1 - }) - }) it('keeps same-id parent and child lineages partitioned by host', () => { const parentA = { ...parent, hostId: 'local' as const } @@ -156,216 +83,6 @@ describe('buildRows workspace lineage nesting', () => { ]) }) - it('nests stable-update resolved legacy lineage when generalized lineage is absent', () => { - const parentId = - '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/assigned-issues' - const childId = - '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9276-nested-ssh-runtime-routing' - const secondChildId = - '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9744-terminal-close-lifecycle' - const resolvedParent: ResolvedLineageWorktree = { - ...parent, - id: parentId, - instanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c', - lineage: null, - workspaceLineage: null - } - const resolvedLineage: WorktreeLineage = { - ...lineage, - worktreeId: childId, - worktreeInstanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b', - parentWorktreeId: parentId, - parentWorktreeInstanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c', - capture: { source: 'explicit-cli-flag', confidence: 'explicit' } - } - const resolvedChild: ResolvedLineageWorktree = { - ...child, - id: childId, - instanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b', - lineage: resolvedLineage, - workspaceLineage: null - } - const secondResolvedLineage: WorktreeLineage = { - ...resolvedLineage, - worktreeId: secondChildId, - worktreeInstanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417' - } - const secondResolvedChild: ResolvedLineageWorktree = { - ...child, - id: secondChildId, - instanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417', - lineage: secondResolvedLineage, - workspaceLineage: null - } - - const rows = buildRows( - 'none', - [secondResolvedChild, resolvedChild, resolvedParent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [resolvedParent.id, resolvedParent], - [resolvedChild.id, resolvedChild], - [secondResolvedChild.id, secondResolvedChild] - ]), - true - ) - - const items = rows.filter((row) => row.type === 'item') - expect(items.map((row) => [row.worktree.id, row.depth])).toEqual([ - [parentId, 0], - [secondChildId, 1], - [childId, 1] - ]) - expect(items[0]).toMatchObject({ - lineageChildCount: 2, - lineageChildWorktreeIds: [secondChildId, childId], - lineageCollapsed: false - }) - }) - - it('rejects stale resolved lineage after a parent instance is replaced', () => { - const resolvedChild: ResolvedLineageWorktree = { - ...child, - lineage: { ...lineage, parentWorktreeInstanceId: 'replaced-parent-instance' } - } - const rows = buildRows( - 'none', - [resolvedChild, parent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [parent.id, parent], - [resolvedChild.id, resolvedChild] - ]), - true - ) - - expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0]) - }) - - it('keeps mixed cyclic lineage participants visible as roots', () => { - const parentLineage: WorktreeLineage = { - ...lineage, - worktreeId: parent.id, - worktreeInstanceId: parent.instanceId!, - parentWorktreeId: child.id, - parentWorktreeInstanceId: child.instanceId! - } - const rows = buildRows( - 'none', - [grandchild, child, parent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage, [parent.id]: parentLineage }, - new Map([ - [parent.id, parent], - [child.id, child], - [grandchild.id, grandchild] - ]), - true - ) - - expect( - rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) - ).toEqual([ - [grandchild.id, 0], - [child.id, 0], - [parent.id, 0] - ]) - }) - - it('resolves inline-only ancestor chains for reveal and temporary picker expansion', () => { - const resolvedChild: ResolvedLineageWorktree = { ...child, lineage } - const resolvedGrandchild: ResolvedLineageWorktree = { - ...grandchild, - lineage: grandchildLineage - } - const worktreeMap = new Map([ - [parent.id, parent], - [resolvedChild.id, resolvedChild], - [resolvedGrandchild.id, resolvedGrandchild] - ]) - - expect( - getWorktreeLineageAncestors(resolvedGrandchild, {}, worktreeMap).map( - (worktree) => worktree.id - ) - ).toEqual([child.id, parent.id]) - }) - - it('keeps a resolved child at the root when its parent is missing', () => { - const resolvedChild: ResolvedLineageWorktree = { ...child, lineage } - const rows = buildRows( - 'none', - [resolvedChild], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[child.id, resolvedChild]]), - true - ) - - expect(rows.find((row) => row.type === 'item')).toMatchObject({ depth: 0 }) - }) - - it.each([ - ['repo', { repoId: 'other-repo' }, [0, 1]], - ['host', { hostId: 'ssh:other-host' as const }, [0, 0]], - ['project', { projectId: 'github:other/project' }, [0, 1]] - ] as const)('applies the known %s lineage boundary', (_label, boundary, depths) => { - const boundedParent = { - ...parent, - repoId: 'repo-1', - hostId: 'local' as const, - projectId: 'github:stablyai/orca', - ...boundary - } - const boundedChild: ResolvedLineageWorktree = { - ...child, - repoId: 'repo-1', - hostId: 'local' as const, - projectId: 'github:stablyai/orca', - lineage - } - const rows = buildRows( - 'none', - [boundedChild, boundedParent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [boundedParent.id, boundedParent], - [boundedChild.id, boundedChild] - ]), - true - ) - - expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual(depths) - }) - it('keeps the hydrated lineage side-map authoritative when inline metadata disagrees', () => { const otherParent = { ...parent, @@ -408,261 +125,4 @@ describe('buildRows workspace lineage nesting', () => { [child.id, 1] ]) }) - - it('supports nested lineage chains beyond one level', () => { - const rows = buildRows( - 'none', - [grandchild, child, parent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage, [grandchild.id]: grandchildLineage }, - new Map([ - [parent.id, parent], - [child.id, child], - [grandchild.id, grandchild] - ]), - true - ) - - const items = rows.filter((row) => row.type === 'item') - expect(items.map((row) => row.worktree.id)).toEqual([parent.id, child.id, grandchild.id]) - expect(items[0]).toMatchObject({ - type: 'item', - depth: 0, - lineageChildCount: 1, - lineageCollapsed: false - }) - expect(items[1]).toMatchObject({ - type: 'item', - worktree: { id: child.id }, - depth: 1, - lineageChildCount: 1 - }) - expect(items[2]).toMatchObject({ - type: 'item', - worktree: { id: grandchild.id }, - depth: 2, - lineageChildCount: 0 - }) - }) - - it('collapses descendants under lineage parents', () => { - const rows = buildRows( - 'none', - [grandchild, child, parent], - repoMap, - null, - new Set([getLineageGroupKey(parent.id)]), - undefined, - undefined, - undefined, - { [child.id]: lineage, [grandchild.id]: grandchildLineage }, - new Map([ - [parent.id, parent], - [child.id, child], - [grandchild.id, grandchild] - ]), - true - ) - - const items = rows.filter((row) => row.type === 'item') - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ - type: 'item', - worktree: { id: parent.id }, - lineageChildCount: 1, - lineageChildWorktreeIds: [child.id], - lineageCollapsed: true - }) - }) - - it('does not create a parent group for stale instance links', () => { - const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' } - const rows = buildRows( - 'none', - [child], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: staleLineage }, - new Map([ - [parent.id, parent], - [child.id, child] - ]), - true - ) - - const item = rows.find((row) => row.type === 'item') - expect(item).toMatchObject({ - type: 'item', - worktree: { id: child.id }, - depth: 0 - }) - }) - - it('marks stale instance links as missing for shared context-menu validation', () => { - const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' } - const info = getLineageRenderInfo( - child, - { [child.id]: staleLineage }, - new Map([ - [parent.id, parent], - [child.id, child] - ]), - new Set() - ) - - expect(info).toMatchObject({ state: 'missing' }) - }) - - it('nests unpinned children under a pinned parent in Pinned', () => { - const pinnedParent = { ...parent, isPinned: true } - const rows = buildRows( - 'none', - [child, pinnedParent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage }, - new Map([ - [pinnedParent.id, pinnedParent], - [child.id, child] - ]), - true - ) - - const items = rows.filter((row) => row.type === 'item') - expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 }) - expect(items.map((row) => [row.worktree.id, row.depth, row.sectionKey])).toEqual([ - [pinnedParent.id, 0, 'pinned'], - [child.id, 1, 'pinned'] - ]) - expect(rows.some((row) => row.type === 'header' && row.key === 'all')).toBe(false) - }) - - it('nests grandchildren under a pinned ancestor in Pinned', () => { - const pinnedParent = { ...parent, isPinned: true } - const rows = buildRows( - 'none', - [grandchild, child, pinnedParent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage, [grandchild.id]: grandchildLineage }, - new Map([ - [pinnedParent.id, pinnedParent], - [child.id, child], - [grandchild.id, grandchild] - ]), - true - ) - - expect( - rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) - ).toEqual([ - [pinnedParent.id, 0], - [child.id, 1], - [grandchild.id, 2] - ]) - }) - - it('duplicates a pinned parent tree into All when the policy allows it', () => { - const pinnedParent = { ...parent, isPinned: true } - const rows = buildRows( - 'none', - [child, pinnedParent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage }, - new Map([ - [pinnedParent.id, pinnedParent], - [child.id, child] - ]), - true, - { showPinnedWorktreesInGroups: true } as never - ) - - expect( - rows - .filter((row) => row.type === 'item') - .map((row) => [row.sectionKey, row.worktree.id, row.depth]) - ).toEqual([ - ['pinned', pinnedParent.id, 0], - ['pinned', child.id, 1], - ['all', pinnedParent.id, 0], - ['all', child.id, 1] - ]) - }) - - it('nests a pinned child under its pinned parent in Pinned', () => { - const pinnedParent = { ...parent, isPinned: true } - const pinnedChild = { ...child, isPinned: true } - const rows = buildRows( - 'none', - [pinnedChild, pinnedParent], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage }, - new Map([ - [pinnedParent.id, pinnedParent], - [pinnedChild.id, pinnedChild] - ]), - true - ) - - expect( - rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) - ).toEqual([ - [pinnedParent.id, 0], - [pinnedChild.id, 1] - ]) - }) - - it('keeps pinned children in Pinned without a parent badge', () => { - const pinnedChild = { ...child, isPinned: true } - const rows = buildRows( - 'none', - [parent, pinnedChild], - repoMap, - null, - new Set(), - undefined, - undefined, - undefined, - { [child.id]: lineage }, - new Map([ - [parent.id, parent], - [child.id, pinnedChild] - ]), - true - ) - - expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' }) - expect(rows[1]).toMatchObject({ - type: 'item', - worktree: { id: child.id } - }) - expect(rows[1]).not.toHaveProperty('parentLabel') - }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-nested-project-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-nested-project-groups.test.ts deleted file mode 100644 index cf63d4e6d46..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-nested-project-groups.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildRows } from './build-rows.test-fixture' -import { getGroupKeysForWorktree } from '../../../../../../shared/sidebar-list/worktree-group-keys' -import { repo, worktree, repoMap } from './worktree-list-groups-test-fixtures' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' - -describe('project groups', () => { - it('renders nested Project Groups before repos assigned to their leaf group', () => { - const rootGroup: ProjectGroup = { - id: 'group-root', - name: 'Services', - parentPath: '/monorepo', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const childGroup: ProjectGroup = { - ...rootGroup, - id: 'group-payments', - name: 'payments', - parentPath: '/monorepo/services/payments', - parentGroupId: rootGroup.id, - tabOrder: 1 - } - const groupedRepo: Repo = { - ...repo, - id: 'repo-payments-api', - displayName: 'api', - projectGroupId: childGroup.id, - projectGroupOrder: 0 - } - const groupedWorktree: Worktree = { - ...worktree, - id: 'wt-payments-api', - repoId: groupedRepo.id - } - - const rows = buildRows( - 'repo', - [groupedWorktree], - new Map([[groupedRepo.id, groupedRepo]]), - null, - new Set(), - new Map([[groupedRepo.id, 0]]), - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [rootGroup, childGroup] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-root', - 'project-group:group-payments', - 'repo:repo-payments-api' - ]) - expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( - [0, 1, 2] - ) - expect(rows.find((row) => row.type === 'item')).toMatchObject({ - type: 'item', - groupDepth: 2 - }) - }) - - it('renders folder workspaces under their owning folder-backed Project Group', () => { - const group: ProjectGroup = { - id: 'group-root', - name: 'Platform', - parentPath: '/monorepo', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const folderWorkspace: FolderWorkspace = { - id: 'folder-workspace-1', - projectGroupId: group.id, - name: 'Refund fix', - folderPath: '/monorepo', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 10, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } - - const rows = buildRows( - 'repo', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - undefined, - undefined, - undefined, - false, - undefined, - [group], - new Set(), - new Map(), - new Map(), - [], - undefined, - [folderWorkspace] - ) - - expect(rows).toMatchObject([ - { - type: 'header', - key: 'project-group:group-root', - count: 1 - }, - { - type: 'folder-workspace', - folderWorkspace: { id: 'folder-workspace-1' }, - projectGroup: { id: 'group-root' }, - groupDepth: 1 - } - ]) - }) - - it('preserves nested Project Group depth for folder workspace rows', () => { - const rootGroup: ProjectGroup = { - id: 'group-root', - name: 'Platform', - parentPath: '/monorepo', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const childGroup: ProjectGroup = { - id: 'group-shared', - name: 'packages/shared', - parentPath: '/monorepo/packages/shared', - parentGroupId: rootGroup.id, - createdFrom: 'folder-scan', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: 2, - updatedAt: 2 - } - const folderWorkspace: FolderWorkspace = { - id: 'folder-workspace-nested', - projectGroupId: childGroup.id, - name: 'Shared package work', - folderPath: '/monorepo/packages/shared', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 10, - lastActivityAt: 0, - createdAt: 3, - updatedAt: 3 - } - - const rows = buildRows( - 'repo', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - undefined, - undefined, - undefined, - false, - undefined, - [rootGroup, childGroup], - new Set(), - new Map(), - new Map(), - [], - undefined, - [folderWorkspace] - ) - - expect(rows).toMatchObject([ - { - type: 'header', - key: 'project-group:group-root', - projectGroupDepth: 0 - }, - { - type: 'header', - key: 'project-group:group-shared', - projectGroupDepth: 1 - }, - { - type: 'folder-workspace', - folderWorkspace: { id: 'folder-workspace-nested' }, - groupDepth: 2 - } - ]) - }) - - it('does not render folder workspaces under non-folder Project Groups', () => { - const group: ProjectGroup = { - id: 'group-manual', - name: 'Manual', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const folderWorkspace: FolderWorkspace = { - id: 'folder-workspace-1', - projectGroupId: group.id, - name: 'Hidden', - folderPath: '/monorepo', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 10, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } - - const rows = buildRows( - 'repo', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - undefined, - undefined, - undefined, - false, - undefined, - [group], - new Set(), - new Map(), - new Map(), - [], - undefined, - [folderWorkspace] - ) - - expect(rows).toMatchObject([ - { - type: 'header', - key: 'project-group:group-manual', - count: 0 - } - ]) - expect(rows.some((row) => row.type === 'folder-workspace')).toBe(false) - }) - - it('renders imported repos under nested Project Groups before worktree rows load', () => { - const rootGroup: ProjectGroup = { - id: 'group-root', - name: 'Root', - parentPath: '/monorepo', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const platformGroup: ProjectGroup = { - ...rootGroup, - id: 'group-platform', - name: 'Platform', - parentGroupId: rootGroup.id, - tabOrder: 1 - } - const servicesGroup: ProjectGroup = { - ...rootGroup, - id: 'group-services', - name: 'Services', - parentGroupId: platformGroup.id, - tabOrder: 2 - } - const serviceA: Repo = { - ...repo, - id: 'repo-service-a', - displayName: 'service-a', - projectGroupId: servicesGroup.id, - projectGroupOrder: 0 - } - const serviceB: Repo = { - ...repo, - id: 'repo-service-b', - displayName: 'service-b', - projectGroupId: servicesGroup.id, - projectGroupOrder: 1 - } - - const rows = buildRows( - 'repo', - [], - new Map([ - [serviceA.id, serviceA], - [serviceB.id, serviceB] - ]), - null, - new Set(), - new Map([ - [serviceA.id, 0], - [serviceB.id, 1] - ]), - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [rootGroup, platformGroup, servicesGroup], - new Set([serviceA.id, serviceB.id]) - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-root', - 'project-group:group-platform', - 'project-group:group-services', - 'repo:repo-service-a', - 'repo:repo-service-b' - ]) - expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( - [0, 1, 2, 3, 3] - ) - }) - - it('returns both parent Project Group and repo keys for grouped repo reveals', () => { - const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - - expect( - getGroupKeysForWorktree( - 'repo', - worktree, - new Map([[groupedRepo.id, groupedRepo]]), - null, - undefined, - [group] - ) - ).toEqual(['project-group:group-1', 'repo:repo-1']) - }) - - it('returns only the repo key for missing Project Group metadata reveals', () => { - const groupedRepo: Repo = { ...repo, projectGroupId: 'missing-group' } - const loadedGroup: ProjectGroup = { - id: 'loaded-group', - name: 'Loaded', - parentPath: '/loaded', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - - expect( - getGroupKeysForWorktree( - 'repo', - worktree, - new Map([[groupedRepo.id, groupedRepo]]), - null, - undefined, - [loadedGroup] - ) - ).toEqual(['repo:repo-1']) - }) - - it('returns only the repo key for ungrouped repo reveals', () => { - expect(getGroupKeysForWorktree('repo', worktree, repoMap, null)).toEqual(['repo:repo-1']) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-groups.test.ts deleted file mode 100644 index ae0fd6d0af3..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-groups.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildRows } from './build-rows.test-fixture' -import { repo, worktree, repoMap } from './worktree-list-groups-test-fixtures' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' - -describe('project groups', () => { - it('keeps empty project groups visible in project grouping mode', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - - const rows = buildRows( - 'repo', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(), - false, - undefined, - [group] - ) - - expect(rows).toEqual([ - expect.objectContaining({ - type: 'header', - key: 'project-group:group-1', - label: 'Platform', - projectGroup: group - }) - ]) - }) - - it('renders grouped repos before their visible worktrees are loaded', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const groupedRepo: Repo = { ...repo, projectGroupId: group.id } - - const rows = buildRows( - 'repo', - [], - new Map([[groupedRepo.id, groupedRepo]]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(), - false, - undefined, - [group], - new Set([groupedRepo.id]) - ) - - expect(rows[0]).toMatchObject({ - type: 'header', - key: 'project-group:group-1' - }) - expect(rows[1]).toMatchObject({ - type: 'header', - key: 'repo:repo-1', - projectGroupDepth: 1 - }) - }) - - it('does not resurrect filtered repos as empty Project Group headers', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const groupedRepo: Repo = { ...repo, projectGroupId: group.id } - - const rows = buildRows( - 'repo', - [], - new Map([[groupedRepo.id, groupedRepo]]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(), - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1' - ]) - expect(rows[0]).toMatchObject({ label: 'Platform' }) - }) - - it('keeps sleep-filtered Project Group members as empty project headers', () => { - // Hide sleeping removes cards, but placeholders must retain project headers and accurate group counts. - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const sleepingRepo: Repo = { - ...repo, - id: 'repo-sleeping', - displayName: 'sleeping-project', - projectGroupId: group.id - } - const awakeRepo: Repo = { - ...repo, - id: 'repo-awake', - displayName: 'awake-project', - projectGroupId: group.id - } - const awakeWorktree: Worktree = { - ...worktree, - id: 'wt-awake', - repoId: awakeRepo.id, - path: '/tmp/awake' - } - - const rows = buildRows( - 'repo', - [awakeWorktree], - new Map([ - [sleepingRepo.id, sleepingRepo], - [awakeRepo.id, awakeRepo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([[awakeWorktree.id, awakeWorktree]]), - false, - undefined, - [group], - new Set([sleepingRepo.id]) - ) - - expect(rows[0]).toMatchObject({ - type: 'header', - key: 'project-group:group-1', - count: 2 - }) - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - `repo:${awakeRepo.id}`, - `repo:${sleepingRepo.id}` - ]) - }) - - it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - - const rows = buildRows( - 'repo', - [worktree], - repoMap, - null, - new Set(), - new Map([[repo.id, 0]]), - undefined, - 'manual', - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-1' - ]) - }) - - it('renders repos whose Project Group metadata is missing as top-level repo rows', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoWithMissingGroup: Repo = { ...repo, projectGroupId: 'missing-group' } - - const rows = buildRows( - 'repo', - [worktree], - new Map([[repoWithMissingGroup.id, repoWithMissingGroup]]), - null, - new Set(), - new Map([[repoWithMissingGroup.id, 0]]), - undefined, - 'manual', - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-1' - ]) - expect(rows.find((row) => row.type === 'header' && row.key === 'repo:repo-1')).toMatchObject({ - projectGroupDepth: 0 - }) - }) - - it('does not render collapsed child-group repos as missing metadata fallbacks', () => { - const parentGroup: ProjectGroup = { - id: 'parent-group', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const childGroup: ProjectGroup = { - ...parentGroup, - id: 'child-group', - name: 'Services', - parentPath: '/platform/services', - parentGroupId: parentGroup.id - } - const repoInChildGroup: Repo = { ...repo, projectGroupId: childGroup.id } - - const rows = buildRows( - 'repo', - [worktree], - new Map([[repoInChildGroup.id, repoInChildGroup]]), - null, - new Set(['project-group:parent-group']), - new Map([[repoInChildGroup.id, 0]]), - undefined, - 'manual', - {}, - new Map([[worktree.id, worktree]]), - false, - undefined, - [parentGroup, childGroup] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:parent-group' - ]) - }) - - it('disambiguates duplicate top-level repo basenames without renaming repos', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const paymentsApi: Repo = { - ...repo, - id: 'repo-payments-api', - path: '/workspace/platform/payments/api', - displayName: 'api' - } - const billingApi: Repo = { - ...repo, - id: 'repo-billing-api', - path: '/workspace/platform/billing/api', - displayName: 'api' - } - const webRepo: Repo = { - ...repo, - id: 'repo-web', - path: '/workspace/platform/web', - displayName: 'web' - } - const repos = new Map([ - [paymentsApi.id, paymentsApi], - [billingApi.id, billingApi], - [webRepo.id, webRepo] - ]) - const worktrees = [ - { ...worktree, id: 'wt-payments-api', repoId: paymentsApi.id }, - { ...worktree, id: 'wt-billing-api', repoId: billingApi.id }, - { ...worktree, id: 'wt-web', repoId: webRepo.id } - ] - - const rows = buildRows( - 'repo', - worktrees, - repos, - null, - new Set(), - new Map([ - [paymentsApi.id, 0], - [billingApi.id, 1], - [webRepo.id, 2] - ]), - undefined, - 'manual', - {}, - new Map(worktrees.map((entry) => [entry.id, entry])), - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.label)).toEqual([ - 'Platform', - 'payments/api', - 'billing/api', - 'web' - ]) - expect(paymentsApi.displayName).toBe('api') - expect(billingApi.displayName).toBe('api') - }) - - it('disambiguates duplicate repo basenames inside each Project Group scope', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const paymentsApi: Repo = { - ...repo, - id: 'repo-payments-api', - path: '/workspace/platform/payments/api', - displayName: 'api', - projectGroupId: group.id, - projectGroupOrder: 0 - } - const billingApi: Repo = { - ...repo, - id: 'repo-billing-api', - path: '/workspace/platform/billing/api', - displayName: 'api', - projectGroupId: group.id, - projectGroupOrder: 1 - } - const webRepo: Repo = { - ...repo, - id: 'repo-web', - path: '/workspace/platform/web', - displayName: 'web', - projectGroupId: group.id, - projectGroupOrder: 2 - } - const repos = new Map([ - [paymentsApi.id, paymentsApi], - [billingApi.id, billingApi], - [webRepo.id, webRepo] - ]) - const worktrees = [ - { ...worktree, id: 'wt-payments-api', repoId: paymentsApi.id }, - { ...worktree, id: 'wt-billing-api', repoId: billingApi.id }, - { ...worktree, id: 'wt-web', repoId: webRepo.id } - ] - - const rows = buildRows( - 'repo', - worktrees, - repos, - null, - new Set(), - new Map([ - [paymentsApi.id, 0], - [billingApi.id, 1], - [webRepo.id, 2] - ]), - undefined, - 'manual', - {}, - new Map(worktrees.map((entry) => [entry.id, entry])), - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.label)).toEqual([ - 'Platform', - 'payments/api', - 'billing/api', - 'web' - ]) - expect(paymentsApi.displayName).toBe('api') - expect(billingApi.displayName).toBe('api') - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-host-setups.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-host-setups.test.ts index 4c77bbccc6d..3c3d7536315 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-host-setups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-host-setups.test.ts @@ -1,691 +1,13 @@ import { describe, expect, it } from 'vitest' -import { projectHostSetupProjectionFromRepos } from '../../../../../../shared/project-host-setup-projection' + import { buildRows } from './build-rows.test-fixture' -import { getGroupKeyForWorktree } from '../../../../../../shared/sidebar-list/worktree-group-keys' -import { - LOCAL_HOST_LABEL, - repo, - worktree, - remoteRepo, - remoteWorktree, - project, - projectHostSetups, - makeDetectedWorktree -} from './worktree-list-groups-test-fixtures' -import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types' + +import { repo, worktree, project, projectHostSetups } from './worktree-list-groups-test-fixtures' +import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { Repo } from '../../../../../../shared/repo-types' import type { Worktree } from '../../../../../../shared/worktree/types' describe('buildRows with pinned worktrees', () => { - it('groups multiple host setups for the same project under one project header', () => { - const rows = buildRows( - 'repo', - [worktree, remoteWorktree], - new Map([ - [repo.id, repo], - [remoteRepo.id, remoteRepo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [remoteWorktree.id, remoteWorktree] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { projects: [project], projectHostSetups } - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, - { type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL }, - { type: 'item', worktree: { id: remoteWorktree.id }, hostContextLabel: 'gpu-vm' } - ]) - }) - - it('keeps the cross-host project header label when another project section renders', () => { - // Filtering other sections must not replace a project’s display name with its anchor checkout’s name. - const otherRepo: Repo = { - ...repo, - id: 'repo-other', - path: '/tmp/design-assets', - displayName: 'design-assets' - } - const otherWorktree: Worktree = { - ...worktree, - id: 'wt-other', - repoId: otherRepo.id, - path: '/tmp/design-assets-feature', - displayName: 'palette' - } - const buildHeaders = (extraWorktrees: Worktree[], extraRepos: Repo[]) => { - const worktrees = [worktree, remoteWorktree, ...extraWorktrees] - const rows = buildRows( - 'repo', - worktrees, - new Map([ - [repo.id, repo], - [remoteRepo.id, remoteRepo], - ...extraRepos.map((entry): [string, Repo] => [entry.id, entry]) - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map(worktrees.map((entry) => [entry.id, entry])), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { projects: [project], projectHostSetups } - ) - return rows.filter((row) => row.type === 'header') - } - - expect(buildHeaders([], [])).toMatchObject([ - { key: 'project:github:stablyai/orca', label: 'Orca' } - ]) - expect(buildHeaders([otherWorktree], [otherRepo])).toMatchObject([ - { key: 'project:github:stablyai/orca', label: 'Orca' }, - { key: 'repo:repo-other', label: 'design-assets' } - ]) - }) - - it('renders same-project records with git remote identity as one mixed-host project header', () => { - const localRepo: Repo = { - ...repo, - id: 'local-sample-app', - path: '/Users/alice/work/sample-app', - displayName: 'sample-app', - gitRemoteIdentity: { - canonicalKey: 'git.company.test/team/sample-app', - remoteName: 'origin', - remoteUrl: 'git@git.company.test:team/sample-app.git' - } - } - const sshRepo: Repo = { - ...repo, - id: 'ssh-sample-app', - path: '/home/alice/src/sample-app', - displayName: 'sample-app', - connectionId: 'build server', - gitRemoteIdentity: { - canonicalKey: 'git.company.test/team/sample-app', - remoteName: 'origin', - remoteUrl: 'https://git.company.test/team/sample-app.git' - } - } - const runtimeRepo: Repo = { - ...repo, - id: 'runtime-sample-app', - path: '/workspace/sample-app', - displayName: 'sample-app', - executionHostId: 'runtime:dev-container', - gitRemoteIdentity: { - canonicalKey: 'git.company.test/team/sample-app', - remoteName: 'origin', - remoteUrl: 'ssh://git@git.company.test/team/sample-app.git' - } - } - const localWorktree: Worktree = { - ...worktree, - id: 'wt-local-sample-app', - repoId: localRepo.id, - path: '/Users/alice/work/sample-app-feature' - } - const sshWorktree: Worktree = { - ...worktree, - id: 'wt-ssh-sample-app', - repoId: sshRepo.id, - path: '/home/alice/src/sample-app-feature' - } - const runtimeWorktree: Worktree = { - ...worktree, - id: 'wt-runtime-sample-app', - repoId: runtimeRepo.id, - path: '/workspace/sample-app-feature' - } - const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo, runtimeRepo]) - const rows = buildRows( - 'repo', - [localWorktree, sshWorktree, runtimeWorktree], - new Map([ - [localRepo.id, localRepo], - [sshRepo.id, sshRepo], - [runtimeRepo.id, runtimeRepo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [localWorktree.id, localWorktree], - [sshWorktree.id, sshWorktree], - [runtimeWorktree.id, runtimeWorktree] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: projection.projects, - projectHostSetups: projection.setups - } - ) - - expect(rows).toMatchObject([ - { - type: 'header', - key: 'project:git:git.company.test/team/sample-app', - label: 'sample-app', - count: 3 - }, - { type: 'item', worktree: { id: localWorktree.id }, hostContextLabel: LOCAL_HOST_LABEL }, - { type: 'item', worktree: { id: sshWorktree.id }, hostContextLabel: 'build server' }, - { type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev-container' } - ]) - }) - - it('keeps mixed-host project item order while inserting inbox rows before worktrees', () => { - const localRepo: Repo = { - ...repo, - id: 'local-sample-app', - displayName: 'sample-app', - gitRemoteIdentity: { - canonicalKey: 'git.company.test/team/sample-app', - remoteName: 'origin', - remoteUrl: 'https://git.company.test/team/sample-app.git' - } - } - const sshRepo: Repo = { - ...repo, - id: 'ssh-sample-app', - path: '/home/alice/src/sample-app', - displayName: 'sample-app', - connectionId: 'build server', - gitRemoteIdentity: { - canonicalKey: 'git.company.test/team/sample-app', - remoteName: 'origin', - remoteUrl: 'https://git.company.test/team/sample-app.git' - } - } - const localFirst: Worktree = { - ...worktree, - id: 'wt-local-first', - repoId: localRepo.id, - path: '/Users/alice/work/sample-app-a' - } - const sshWorktree: Worktree = { - ...worktree, - id: 'wt-ssh', - repoId: sshRepo.id, - path: '/home/alice/src/sample-app-b' - } - const localSecond: Worktree = { - ...worktree, - id: 'wt-local-second', - repoId: localRepo.id, - path: '/Users/alice/work/sample-app-c' - } - const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo]) - const rows = buildRows( - 'repo', - [localFirst, sshWorktree, localSecond], - new Map([ - [localRepo.id, localRepo], - [sshRepo.id, sshRepo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [localFirst.id, localFirst], - [sshWorktree.id, sshWorktree], - [localSecond.id, localSecond] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map([ - [ - localRepo.id, - { repo: localRepo, inboxWorktrees: [makeDetectedWorktree({ id: 'local-inbox' })] } - ], - [sshRepo.id, { repo: sshRepo, inboxWorktrees: [makeDetectedWorktree({ id: 'ssh-inbox' })] }] - ]), - [], - { - projects: projection.projects, - projectHostSetups: projection.setups - } - ) - - expect(rows).toMatchObject([ - { type: 'header' }, - { type: 'new-external-worktrees-inbox', repo: { id: localRepo.id } }, - { type: 'new-external-worktrees-inbox', repo: { id: sshRepo.id } }, - { type: 'item', worktree: { id: localFirst.id } }, - { type: 'item', worktree: { id: sshWorktree.id } }, - { type: 'item', worktree: { id: localSecond.id } } - ]) - }) - - it('orders project identity headers by the manual repo order anchor', () => { - const analyticsProject: Project = { - ...project, - id: 'github:stablyai/analytics', - displayName: 'Analytics', - sourceRepoIds: ['repo-analytics'] - } - const analyticsRepo: Repo = { - ...repo, - id: 'repo-analytics', - path: '/tmp/analytics', - displayName: 'analytics', - upstream: { owner: 'stablyai', repo: 'analytics' } - } - const analyticsWorktree: Worktree = { - ...worktree, - id: 'wt-analytics', - repoId: analyticsRepo.id, - displayName: 'analytics' - } - const analyticsSetup: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: analyticsRepo.id, - projectId: analyticsProject.id, - repoId: analyticsRepo.id, - path: analyticsRepo.path, - displayName: analyticsRepo.displayName - } - const repoOrder = new Map([ - [repo.id, 0], - [remoteRepo.id, 1], - [analyticsRepo.id, 2] - ]) - - const rows = buildRows( - 'repo', - [worktree, analyticsWorktree, remoteWorktree], - new Map([ - [repo.id, repo], - [remoteRepo.id, remoteRepo], - [analyticsRepo.id, analyticsRepo] - ]), - null, - new Set(), - repoOrder, - undefined, - 'manual', - {}, - new Map([ - [worktree.id, worktree], - [remoteWorktree.id, remoteWorktree], - [analyticsWorktree.id, analyticsWorktree] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [project, analyticsProject], - projectHostSetups: [...projectHostSetups, analyticsSetup] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key)).toEqual([ - 'project:github:stablyai/orca', - 'project:github:stablyai/analytics' - ]) - expect(headers[0]).toMatchObject({ - key: 'project:github:stablyai/orca', - repo: { id: repo.id, badgeColor: repo.badgeColor } - }) - }) - - it('splits same-host checkouts of one project into separate per-setup groups', () => { - const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } - const worktreeB: Worktree = { - ...worktree, - id: 'wt-2', - repoId: repoB.id, - path: '/tmp/orca-2-feature', - displayName: 'feature-b' - } - const localSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: repoB.id, - repoId: repoB.id, - path: repoB.path, - displayName: repoB.displayName - } - const rows = buildRows( - 'repo', - [worktree, worktreeB], - new Map([ - [repo.id, repo], - [repoB.id, repoB] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [worktreeB.id, worktreeB] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }], - projectHostSetups: [projectHostSetups[0]!, localSetupB] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers).toHaveLength(2) - expect(headers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-1', - label: 'orca' - }), - expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-2', - label: 'orca-2' - }) - ]) - ) - }) - - it('splits only the surface with duplicate checkouts', () => { - const localRepoB: Repo = { - ...repo, - id: 'repo-local-b', - path: '/tmp/orca-b', - displayName: 'orca-b' - } - const localWorktreeB: Worktree = { - ...worktree, - id: 'wt-local-b', - repoId: localRepoB.id, - path: '/tmp/orca-b-feature', - displayName: 'feature-b' - } - const localSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: localRepoB.id, - repoId: localRepoB.id, - path: localRepoB.path, - displayName: localRepoB.displayName - } - const rows = buildRows( - 'repo', - [worktree, localWorktreeB, remoteWorktree], - new Map([ - [repo.id, repo], - [localRepoB.id, localRepoB], - [remoteRepo.id, remoteRepo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [localWorktreeB.id, localWorktreeB], - [remoteWorktree.id, remoteWorktree] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.id] }], - projectHostSetups: [projectHostSetups[0]!, localSetupB, projectHostSetups[1]!] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key)).toEqual([ - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b', - 'project:github:stablyai/orca' - ]) - }) - - it('counts projection twins for one directory as one checkout', () => { - const runtimeHostId = 'runtime:m2-air' - const runtimeRepo: Repo = { - ...remoteRepo, - id: 'repo-runtime', - path: '/home/alice/orca-runtime', - connectionId: undefined, - executionHostId: runtimeHostId - } - const runtimeWorktree: Worktree = { - ...remoteWorktree, - id: 'wt-runtime', - repoId: runtimeRepo.id, - path: '/home/alice/orca-runtime-feature' - } - const runtimeSetup: ProjectHostSetup = { - ...projectHostSetups[1]!, - id: runtimeRepo.id, - repoId: runtimeRepo.id, - path: runtimeRepo.path, - hostId: runtimeHostId, - executionHostId: runtimeHostId - } - const derivedSetup: ProjectHostSetup = { - ...projectHostSetups[1]!, - hostId: runtimeHostId, - connectionId: 'intel mac', - executionHostId: runtimeHostId - } - const authoritativeSetup: ProjectHostSetup = { - ...derivedSetup, - id: 'setup-authoritative', - path: `${derivedSetup.path}/`, - connectionId: null, - executionHostId: 'ssh:intel%20mac' - } - const grouping = { - projects: [{ ...project, sourceRepoIds: [remoteRepo.id, runtimeRepo.id] }], - projectHostSetups: [runtimeSetup, derivedSetup, authoritativeSetup] - } - - expect([ - getGroupKeyForWorktree( - 'repo', - remoteWorktree, - new Map([[remoteRepo.id, remoteRepo]]), - null, - undefined, - grouping - ), - getGroupKeyForWorktree( - 'repo', - runtimeWorktree, - new Map([[runtimeRepo.id, runtimeRepo]]), - null, - undefined, - grouping - ) - ]).toEqual(['project:github:stablyai/orca', 'project:github:stablyai/orca']) - }) - - it('keeps Git hosts grouped when folder setups share the project identity', () => { - const windowsHostId = 'runtime:windows-server' - const windowsRepo: Repo = { - ...repo, - id: 'repo-windows', - path: 'C:\\Users\\neil\\orca\\orca', - executionHostId: windowsHostId - } - const folderRepoA: Repo = { - ...repo, - id: 'folder-qa-a', - path: '/tmp/pr11751-folder-qa', - displayName: 'pr11751-folder-qa', - kind: 'folder' - } - const folderRepoB: Repo = { - ...folderRepoA, - id: 'folder-qa-b', - path: '/tmp/pr11767-runtime-folder', - displayName: 'pr11767-runtime-folder' - } - const setups: ProjectHostSetup[] = [ - projectHostSetups[0]!, - { ...projectHostSetups[0]!, id: 'projection-twin' }, - projectHostSetups[1]!, - { - ...projectHostSetups[0]!, - id: windowsRepo.id, - repoId: windowsRepo.id, - path: windowsRepo.path, - hostId: windowsHostId, - executionHostId: windowsHostId - }, - { - ...projectHostSetups[0]!, - id: folderRepoA.id, - repoId: folderRepoA.id, - path: folderRepoA.path, - displayName: folderRepoA.displayName, - kind: 'folder' - }, - { - ...projectHostSetups[0]!, - id: folderRepoB.id, - repoId: folderRepoB.id, - path: folderRepoB.path, - displayName: folderRepoB.displayName, - kind: 'folder' - } - ] - const repos = [repo, remoteRepo, windowsRepo, folderRepoA, folderRepoB] - const grouping = { - projects: [{ ...project, sourceRepoIds: repos.map((entry) => entry.id) }], - projectHostSetups: setups - } - - const groupKeys = repos.map((entry) => - getGroupKeyForWorktree( - 'repo', - { ...worktree, id: `wt-${entry.id}`, repoId: entry.id, path: entry.path }, - new Map([[entry.id, entry]]), - null, - undefined, - grouping - ) - ) - expect(new Set(groupKeys)).toEqual(new Set(['project:github:stablyai/orca'])) - }) - - it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => { - const runtimeRepoB: Repo = { - ...repo, - id: 'repo-runtime-b', - path: '/tmp/orca-runtime-b', - displayName: 'orca-runtime-b' - } - const runtimeWorktreeB: Worktree = { - ...worktree, - id: 'wt-runtime-b', - repoId: runtimeRepoB.id, - path: '/tmp/orca-runtime-b-feature', - displayName: 'feature-runtime-b' - } - // Provisioned copies share project identity and nest rather than split real checkouts into extra headers. - const runtimeSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: runtimeRepoB.id, - repoId: runtimeRepoB.id, - path: runtimeRepoB.path, - displayName: runtimeRepoB.displayName, - setupMethod: 'provisioned' - } - const rows = buildRows( - 'repo', - [worktree, runtimeWorktreeB], - new Map([ - [repo.id, repo], - [runtimeRepoB.id, runtimeRepoB] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [runtimeWorktreeB.id, runtimeWorktreeB] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [{ ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }], - projectHostSetups: [projectHostSetups[0]!, runtimeSetupB] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers).toHaveLength(1) - expect(headers[0]).toMatchObject({ - key: 'project:github:stablyai/orca', - label: 'Orca', - count: 2 - }) - }) - it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { // Exercise real-checkout splitting and provisioned-copy nesting together on one host. const localRepoB: Repo = { diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-order.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-order.test.ts index bfaf51fe705..31a2022cb5c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-order.test.ts @@ -1,146 +1,11 @@ import { describe, expect, it } from 'vitest' import { buildRows } from './build-rows.test-fixture' import { repo, worktree } from './worktree-list-groups-test-fixtures' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' + import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' describe('buildRows project grouping order', () => { - const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha' } - const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta' } - const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma' } - const map = new Map([ - [repoA.id, repoA], - [repoB.id, repoB], - [repoC.id, repoC] - ]) // A stale sibling precedes A’s fresh child to require max-child activity ranking. - const wA: Worktree = { - ...worktree, - id: 'wt-a', - repoId: repoA.id, - displayName: 'a', - lastActivityAt: 200 - } - const wAStale: Worktree = { - ...worktree, - id: 'wt-a-stale', - repoId: repoA.id, - displayName: 'a2', - lastActivityAt: 50 - } - const wB: Worktree = { - ...worktree, - id: 'wt-b', - repoId: repoB.id, - displayName: 'b', - lastActivityAt: 100 - } - const wC: Worktree = { - ...worktree, - id: 'wt-c', - repoId: repoC.id, - displayName: 'c', - lastActivityAt: 300 - } - - it('orders repo headers by explicit repoOrder, not first-encounter', () => { - const repoOrder = new Map([ - [repoB.id, 0], - [repoA.id, 1], - [repoC.id, 2] - ]) - const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder) - const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) - expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) - }) - - it('places unknown repo ids last and sorts them by label', () => { - const repoOrder = new Map([[repoB.id, 0]]) - const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder) - const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) - expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) - }) - - it('orders repo headers by max(lastActivityAt) per repo in Recent mode', () => { - // Pass name order and conflicting manual ranks so Recent must compute activity ranks itself. - const repoOrder = new Map([ - [repoB.id, 0], - [repoA.id, 1], - [repoC.id, 2] - ]) - const rows = buildRows( - 'repo', - [wA, wB, wC], - map, - null, - new Set(), - repoOrder, - undefined, - 'recent' - ) - const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) - expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b']) - }) - - it("uses each repo's freshest visible child, not its first, in Recent mode", () => { - const rows = buildRows( - 'repo', - [wAStale, wA, wB, wC], - map, - null, - new Set(), - undefined, - undefined, - 'recent' - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-c' }, - { type: 'item', worktree: { id: 'wt-c' } }, - { type: 'header', key: 'repo:repo-a' }, - // Child rows keep their input order; only the header rank uses max activity. - { type: 'item', worktree: { id: 'wt-a-stale' } }, - { type: 'item', worktree: { id: 'wt-a' } }, - { type: 'header', key: 'repo:repo-b' }, - { type: 'item', worktree: { id: 'wt-b' } } - ]) - }) - - it('keeps the main workspace first inside its project group in Recent mode', () => { - const main = { - ...wA, - id: 'wt-a-main', - displayName: 'main', - isMainWorktree: true, - lastActivityAt: 10 - } - const freshChild = { - ...wA, - id: 'wt-a-fresh-child', - displayName: 'fresh-child', - isMainWorktree: false, - lastActivityAt: 500 - } - const rows = buildRows( - 'repo', - [freshChild, wB, main], - map, - null, - new Set(), - undefined, - undefined, - 'recent' - ) - - expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-a' }, - { type: 'item', worktree: { id: 'wt-a-main' } }, - { type: 'item', worktree: { id: 'wt-a-fresh-child' } }, - { type: 'header', key: 'repo:repo-b' }, - { type: 'item', worktree: { id: 'wt-b' } } - ]) - }) it('builds rows for a very large repo-group list', () => { const count = 130_000 @@ -158,344 +23,3 @@ describe('buildRows project grouping order', () => { expect(rows.at(-1)).toMatchObject({ type: 'item', worktree: { id: 'wt-129999' } }) }) }) - -describe('buildRows Recent project order fallbacks', () => { - const active: Repo = { ...repo, id: 'repo-active', displayName: 'active', addedAt: 0 } - const empty: Repo = { ...repo, id: 'repo-empty', displayName: 'empty', addedAt: 999 } - const map = new Map([ - [active.id, active], - [empty.id, empty] - ]) - const activeWorktree: Worktree = { - ...worktree, - id: 'wt-active', - repoId: active.id, - displayName: 'active', - lastActivityAt: 100 - } - - it('sorts placeholder projects after projects with activity', () => { - // Real workspace activity must outrank an empty project’s larger addedAt fallback. - const rows = buildRows( - 'repo', - [activeWorktree], - map, - null, - new Set(), - undefined, - undefined, - 'recent', - {}, - undefined, - false, - undefined, - [], - new Set([empty.id]) - ) - const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) - expect(headerKeys).toEqual(['repo:repo-active', 'repo:repo-empty']) - }) -}) - -describe('project groups', () => { - it('orders repos inside a Project Group by projectGroupOrder in manual mode', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoA: Repo = { - ...repo, - id: 'repo-a', - displayName: 'alpha', - projectGroupId: group.id, - projectGroupOrder: 1 - } - const repoB: Repo = { - ...repo, - id: 'repo-b', - displayName: 'beta', - projectGroupId: group.id, - projectGroupOrder: 0 - } - const worktreeA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id } - const worktreeB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id } - const groupedMap = new Map([ - [repoA.id, repoA], - [repoB.id, repoB] - ]) - const repoOrder = new Map([ - [repoA.id, 0], - [repoB.id, 1] - ]) - - const rows = buildRows( - 'repo', - [worktreeA, worktreeB], - groupedMap, - null, - new Set(), - repoOrder, - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-b', - 'repo:repo-a' - ]) - }) - - it('falls back to repoOrder for grouped repos missing projectGroupOrder in manual mode', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha', projectGroupId: group.id } - const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta', projectGroupId: group.id } - const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma', projectGroupId: group.id } - const groupedMap = new Map([ - [repoA.id, repoA], - [repoB.id, repoB], - [repoC.id, repoC] - ]) - const repoOrder = new Map([ - [repoA.id, 0], - [repoB.id, 1], - [repoC.id, 2] - ]) - - const rows = buildRows( - 'repo', - [ - { ...worktree, id: 'wt-a', repoId: repoA.id }, - { ...worktree, id: 'wt-b', repoId: repoB.id }, - { ...worktree, id: 'wt-c', repoId: repoC.id } - ], - groupedMap, - null, - new Set(), - repoOrder, - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-a', - 'repo:repo-b', - 'repo:repo-c' - ]) - }) - - it('sorts a dragged project between repo-order fallbacks inside a group', () => { - const group: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha', projectGroupId: group.id } - const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta', projectGroupId: group.id } - const repoC: Repo = { - ...repo, - id: 'repo-c', - displayName: 'gamma', - projectGroupId: group.id, - projectGroupOrder: 500 - } - const groupedMap = new Map([ - [repoA.id, repoA], - [repoB.id, repoB], - [repoC.id, repoC] - ]) - const repoOrder = new Map([ - [repoA.id, 0], - [repoB.id, 1], - [repoC.id, 2] - ]) - - const rows = buildRows( - 'repo', - [ - { ...worktree, id: 'wt-a', repoId: repoA.id }, - { ...worktree, id: 'wt-b', repoId: repoB.id }, - { ...worktree, id: 'wt-c', repoId: repoC.id } - ], - groupedMap, - null, - new Set(), - repoOrder, - undefined, - 'manual', - undefined, - undefined, - false, - undefined, - [group] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-1', - 'repo:repo-a', - 'repo:repo-c', - 'repo:repo-b' - ]) - }) - - it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => { - const groupA: ProjectGroup = { - id: 'group-a', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const groupB: ProjectGroup = { ...groupA, id: 'group-b', name: 'Infra', tabOrder: 0 } - // Recent activity must override conflicting projectGroupOrder within the group. - const repoStale: Repo = { - ...repo, - id: 'repo-stale', - displayName: 'stale', - projectGroupId: groupA.id, - projectGroupOrder: 0 - } - const repoFresh: Repo = { - ...repo, - id: 'repo-fresh', - displayName: 'fresh', - projectGroupId: groupA.id, - projectGroupOrder: 1 - } - const groupedMap = new Map([ - [repoStale.id, repoStale], - [repoFresh.id, repoFresh] - ]) - const worktrees = [ - { ...worktree, id: 'wt-stale', repoId: repoStale.id, lastActivityAt: 10 }, - { ...worktree, id: 'wt-fresh', repoId: repoFresh.id, lastActivityAt: 500 } - ] - - const rows = buildRows( - 'repo', - worktrees, - groupedMap, - null, - new Set(), - undefined, - undefined, - 'recent', - {}, - new Map(worktrees.map((entry) => [entry.id, entry])), - false, - undefined, - // Group headers retain tabOrder regardless of projectOrderBy. - [groupA, groupB] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-b', - 'project-group:group-a', - 'repo:repo-fresh', - 'repo:repo-stale' - ]) - }) - - it('orders Project Group siblings by tabOrder within each parent bucket', () => { - const rootA: ProjectGroup = { - id: 'group-root-a', - name: 'Platform', - parentPath: '/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 20, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const rootB: ProjectGroup = { - ...rootA, - id: 'group-root-b', - name: 'Infrastructure', - tabOrder: 10 - } - const childLate: ProjectGroup = { - ...rootA, - id: 'group-child-late', - name: 'late', - parentGroupId: rootB.id, - tabOrder: 30 - } - const childEarly: ProjectGroup = { - ...rootA, - id: 'group-child-early', - name: 'early', - parentGroupId: rootB.id, - tabOrder: 5 - } - - const rows = buildRows( - 'repo', - [], - new Map(), - null, - new Set(), - undefined, - undefined, - 'recent', - {}, - undefined, - false, - undefined, - [rootA, rootB, childLate, childEarly] - ) - - expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ - 'project-group:group-root-b', - 'project-group:group-child-early', - 'project-group:group-child-late', - 'project-group:group-root-a' - ]) - expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( - [0, 1, 1, 0] - ) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.card-memo-stability.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.card-memo-stability.test.tsx deleted file mode 100644 index efa2d961c53..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.card-memo-stability.test.tsx +++ /dev/null @@ -1,327 +0,0 @@ -// @vitest-environment happy-dom - -vi.mock('@/components/confirmation-dialog-context', () => ({ - useConfirmationDialog: () => vi.fn().mockResolvedValue(false) -})) - -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ProjectHeaderDragContract from '../drag/project-header-drag-contract' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' - -globalThis.IS_REACT_ACT_ENVIRONMENT = true - -const mockStore = vi.hoisted(() => ({ - state: {} as Record, - setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(), - activateWorktreeFromSidebar: vi.fn(), - openModal: vi.fn() -})) - -// Count inner renders so React.memo bailouts remain observable across order-preserving epoch changes. -const cardRenderSpy = vi.hoisted(() => vi.fn()) - -type WorktreeListComponent = React.ComponentType<{ - scrollOffsetRef: React.RefObject - scrollAnchorRef: React.RefObject -}> - -let WorktreeList: WorktreeListComponent - -vi.mock('@/store', () => { - const useAppStore = ((selector: (state: Record) => unknown) => - selector(mockStore.state)) as (( - selector: (state: Record) => unknown - ) => unknown) & { - getState: () => Record - } - useAppStore.getState = () => mockStore.state - return { useAppStore } -}) - -vi.mock('@tanstack/react-virtual', () => ({ - defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => - Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), - measureElement: () => 32, - useVirtualizer: ({ count }: { count: number }) => ({ - elementsCache: new Map(), - getTotalSize: () => count * 96, - getVirtualItems: () => - Array.from({ length: count }, (_, index) => ({ - index, - key: `row-${index}`, - start: index * 96 - })), - measureElement: vi.fn(), - scrollToIndex: vi.fn() - }) -})) - -vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', - useVirtualizedScrollAnchor: vi.fn() -})) - -vi.mock('../drag/project-header-drag', () => ({ - useRepoHeaderDrag: () => ({ - state: { draggingRepoId: null, dropIndicatorY: null }, - onHandlePointerDown: vi.fn() - }) -})) - -vi.mock('../drag/project-header-drag-contract', async (importOriginal) => ({ - ...(await importOriginal()), - isRepoHeaderActionTarget: () => false -})) - -vi.mock('@/components/ui/hover-card', () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) =>
{children}
, - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( - - ), - DropdownMenuSeparator: () =>
, - DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar -})) - -vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) - -vi.mock('../../workspace-card/activity/WorktreeCardAgents', () => ({ - default: () =>
Agent row
, - SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' -})) - -vi.mock('../../workspace-card/surface/WorktreeCard', async () => { - const ReactModule = await import('react') - const MockWorktreeCard = ReactModule.memo(function WorktreeCard({ - worktree - }: { - worktree: Worktree - }) { - cardRenderSpy(worktree.id) - return
- }) - return { default: MockWorktreeCard } -}) - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/tmp/card-memo-stability', - displayName: 'card-memo-stability', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(args: { id: string; displayName: string; sortOrder: number }): Worktree { - return { - id: args.id, - instanceId: `${args.id}-instance`, - repoId: 'repo-1', - path: `/tmp/card-memo-stability/${args.id}`, - displayName: args.displayName, - branch: `${args.id}-branch`, - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: args.sortOrder, - lastActivityAt: args.sortOrder - } -} - -function makeFolderWorkspacePathStatusState(): Record { - return { - fetchFolderWorkspacePathStatus: vi.fn(), - folderWorkspacePathStatuses: {}, - folderWorkspaces: [], - getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), - getFreshFolderWorkspacePathStatus: vi.fn(() => null) - } -} - -function setFlatWorktreeState(): void { - const repo = makeRepo() - const worktrees = [ - makeWorktree({ id: 'wt-a', displayName: 'alpha', sortOrder: 20 }), - makeWorktree({ id: 'wt-b', displayName: 'beta', sortOrder: 10 }) - ] - mockStore.state = { - ...makeFolderWorkspacePathStatusState(), - activeModal: '', - activeView: 'terminal', - activeWorktreeId: null, - agentStatusByPaneKey: {}, - agentStatusEpoch: 0, - browserTabsByWorktree: {}, - clearPendingRevealWorktreeId: vi.fn(), - collapsedGroups: new Set(), - deleteStateByWorktreeId: {}, - detectedWorktreesByRepo: {}, - fetchHostedReviewForBranch: vi.fn(), - fetchIssue: vi.fn(), - fetchLinearIssue: vi.fn(), - filterRepoIds: [], - gitConflictOperationByWorktree: {}, - groupBy: 'none', - hideDefaultBranchWorkspace: false, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - linearStatus: null, - migrationUnsupportedByPtyId: {}, - openModal: mockStore.openModal, - openSettingsPage: vi.fn(), - openSettingsTarget: null, - openTaskPage: vi.fn(), - pendingRevealWorktree: null, - prCache: {}, - projectGroups: [], - ptyIdsByTabId: {}, - recordFeatureInteraction: vi.fn(), - remoteBranchConflictByWorktreeId: {}, - reorderRepos: vi.fn(), - reportVisibleGitHubPRRefreshCandidates: vi.fn(), - setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, - repos: [repo], - retainedAgentsByPaneKey: {}, - revealWorktreeInSidebar: vi.fn(), - runtimePaneTitlesByTabId: {}, - setFilterRepoIds: vi.fn(), - setHideDefaultBranchWorkspace: vi.fn(), - setRenamingWorktreeId: vi.fn(), - setShowSleepingWorkspaces: vi.fn(), - setSortBy: vi.fn(), - setWorktreesPinnedAndReveal: vi.fn(), - settings: null, - showSleepingWorkspaces: true, - sortBy: 'manual', - sortEpoch: 0, - sshConnectedGeneration: 0, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - toggleCollapsedGroup: vi.fn(), - updateRepo: vi.fn(), - updateWorktreeMeta: vi.fn(), - updateWorktreesMeta: vi.fn(), - workspaceHostScope: 'all', - workspacePortScan: null, - workspaceStatuses: [], - worktreeCardProperties: ['status', 'pr', 'comment'] satisfies WorktreeCardProperty[], - worktreeLineageById: {}, - worktreesByRepo: { - [repo.id]: worktrees - } - } -} - -const mountedRoots: Root[] = [] - -async function renderList(root: Root): Promise { - await act(async () => { - // Fresh ref props bypass the list’s own memo to isolate whether individual cards bail out. - root.render( - - ) - }) -} - -describe('WorktreeCard memo bail-out across epoch bumps', () => { - beforeAll(async () => { - WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 60_000) - - beforeEach(() => { - vi.clearAllMocks() - setFlatWorktreeState() - }) - - afterEach(async () => { - await act(async () => { - for (const root of mountedRoots.splice(0)) { - root.unmount() - } - }) - document.body.innerHTML = '' - }) - - it('does not re-render cards on an order-preserving sortEpoch bump', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - - await renderList(root) - expect(container.querySelectorAll('[data-mock-worktree-card]')).toHaveLength(2) - - const countAfterMount = cardRenderSpy.mock.calls.length - await renderList(root) - expect(cardRenderSpy.mock.calls.length).toBe(countAfterMount) - - // Manual sort applies the order-preserving epoch immediately without smart-sort debounce. - mockStore.state = { ...mockStore.state, sortEpoch: 1 } - await renderList(root) - - expect(cardRenderSpy.mock.calls.length).toBe(countAfterMount) - }) - - it('re-renders a card when its own worktree data changes', async () => { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - - await renderList(root) - const countAfterMount = cardRenderSpy.mock.calls.length - - const worktrees = (mockStore.state.worktreesByRepo as Record)['repo-1']! - mockStore.state = { - ...mockStore.state, - sortEpoch: 2, - worktreesByRepo: { - 'repo-1': [{ ...worktrees[0]!, displayName: 'alpha renamed' }, worktrees[1]!] - } - } - await renderList(root) - - expect(cardRenderSpy.mock.calls.length).toBeGreaterThan(countAfterMount) - expect(cardRenderSpy).toHaveBeenCalledWith('wt-a') - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.folder-workspace-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.folder-workspace-rows.test.ts deleted file mode 100644 index d7ea394cc79..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.folder-workspace-rows.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - createAppStoreModuleMock, - createDropdownMenuModuleMock, - createProjectHeaderDragModuleMock, - createReactVirtualModuleMock, - createTooltipModuleMock, - createVirtualizedScrollAnchorModuleMock, - createWorktreeCardAgentsModuleMock, - createWorktreeCardModuleMock, - createWorktreeContextMenuModuleMock, - createWorktreeTitleInlineRenameModuleMock, - loadWorktreeList, - mockStore, - renderWorktreeListMarkup -} from './worktree-list-lineage-card-test-harness' -import { - makeFolderWorkspacePathStatusMockState, - makeFolderWorkspacePathStatusState -} from './worktree-list-lineage-card-test-fixtures' - -vi.mock('@/store', () => createAppStoreModuleMock()) -vi.mock('@tanstack/react-virtual', () => createReactVirtualModuleMock()) -vi.mock('@/hooks/useVirtualizedScrollAnchor', () => createVirtualizedScrollAnchorModuleMock()) -vi.mock('../drag/project-header-drag', () => createProjectHeaderDragModuleMock()) -vi.mock('../../workspace-card/surface/WorktreeCard', () => createWorktreeCardModuleMock()) -vi.mock('../../workspace-card/activity/WorktreeCardAgents', () => - createWorktreeCardAgentsModuleMock() -) -vi.mock('../../workspace-card/identity/WorktreeTitleInlineRename', () => - createWorktreeTitleInlineRenameModuleMock() -) -vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => - createWorktreeContextMenuModuleMock() -) -vi.mock('@/components/ui/tooltip', () => createTooltipModuleMock()) -vi.mock('@/components/ui/dropdown-menu', () => createDropdownMenuModuleMock()) - -function makeFolderWorkspace( - groupId: string, - overrides: Partial = {} -): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: groupId, - name: 'Folder workspace fixture', - folderPath: '/tmp/lineage-order/folder', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - ...overrides - } -} - -function setFolderWorkspaceFixtureState() { - const group: ProjectGroup = { - id: 'folder-group-1', - name: 'Folder Group', - parentPath: '/tmp/lineage-order/folder', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const folderWorkspace = makeFolderWorkspace(group.id) - - mockStore.state = { - ...makeFolderWorkspacePathStatusMockState(), - activeModal: '', - activeView: 'terminal', - activeWorktreeId: folderWorkspaceKey(folderWorkspace.id), - agentStatusEpoch: 0, - agentStatusByPaneKey: {}, - browserTabsByWorktree: {}, - clearPendingRevealWorktreeId: vi.fn(), - collapsedGroups: new Set(), - deleteStateByWorktreeId: {}, - filterRepoIds: [], - ...makeFolderWorkspacePathStatusState(), - folderWorkspaces: [folderWorkspace], - groupBy: 'repo', - hideDefaultBranchWorkspace: false, - issueCache: {}, - migrationUnsupportedByPtyId: {}, - openModal: vi.fn(), - pendingRevealWorktree: null, - prCache: {}, - prVisibleRefreshGeneration: 0, - projectGroups: [group], - ptyIdsByTabId: {}, - reorderRepos: vi.fn(), - reportVisibleGitHubPRRefreshCandidates: vi.fn(), - retainedAgentsByPaneKey: {}, - repos: [], - runtimePaneTitlesByTabId: {}, - setFilterRepoIds: vi.fn(), - setHideDefaultBranchWorkspace: vi.fn(), - setRenamingWorktreeId: vi.fn(), - setShowSleepingWorkspaces: vi.fn(), - setSortBy: vi.fn(), - settings: null, - renamingWorktreeId: null, - showSleepingWorkspaces: true, - sortBy: 'manual', - sortEpoch: 0, - sshConnectedGeneration: 0, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - toggleCollapsedGroup: vi.fn(), - updateWorktreeMeta: vi.fn(), - updateWorktreesMeta: vi.fn(), - workspaceHostScope: 'all', - workspaceStatuses: [], - worktreeCardProperties: ['status', 'inline-agents'], - worktreeLineageById: {}, - worktreesByRepo: {} - } - return { folderWorkspace, group } -} - -describe('WorktreeList lineage child card renderer', () => { - beforeAll(async () => { - await loadWorktreeList() - }, 60_000) - - it.each(['runtime:host-a', 'runtime:host-b'] as const)( - 'marks only the selected folder host %s as active', - async (selectedHost) => { - const { folderWorkspace, group } = setFolderWorkspaceFixtureState() - const hosts = ['runtime:host-a', 'runtime:host-b'] as const - mockStore.state.folderWorkspaces = hosts.map((executionHostId) => ({ - ...folderWorkspace, - executionHostId, - projectGroupId: executionHostId, - name: executionHostId - })) - mockStore.state.projectGroups = hosts.map((executionHostId) => ({ - ...group, - id: executionHostId, - executionHostId - })) - mockStore.state.activeWorkspaceExecutionHostId = selectedHost - const markup = await renderWorktreeListMarkup() - const options = Array.from(markup.matchAll(/]*role="option"[^>]*>/g), ([tag]) => tag) - expect(options).toHaveLength(2) - const active = options.filter((tag) => tag.includes('aria-current="page"')) - expect(active).toHaveLength(1) - expect(active[0]).toContain(`data-worktree-host-identity="${selectedHost}|folder:`) - expect(markup.match(/data-worktree-card-active="true"/g)).toHaveLength(1) - } - ) - - it('points aria-activedescendant at the active folder workspace row', async () => { - setFolderWorkspaceFixtureState() - const markup = await renderWorktreeListMarkup() - - expect(markup).toContain( - 'aria-activedescendant="worktree-list-option-local%7Cfolder%3Afolder-workspace-1"' - ) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx index ab870e38f40..576597ffa7e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -448,39 +448,6 @@ describe('WorktreeCard agent-list <-> child-worktrees expansion coupling', () => clearWorktreeAgentExpansionStateForTests() }) - it('[full mode] both toggles render independently at mount', async () => { - setAgentLineageState({ agentActivityDisplayMode: 'full' }) - const { container } = await renderWorktreeList() - - expect(container.textContent).toContain('Parent Terminal') - expect(container.textContent).not.toContain('PARENT_AGENT_PROMPT') - // Child agents stay collapsed until the user opens them; child worktrees stay open. - expect(container.textContent).not.toContain('CHILD_AGENT_PROMPT') - - expect(childWorktreeChip(container)).not.toBeNull() - expect(agentChildDisclosure(container)).not.toBeNull() - - expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true') - expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') - expect(childWorktreeCardPresent(container)).toBe(true) - }) - - it('[full mode] toggling AGENTS does NOT change the child-worktrees chip (agents -> children uncoupled)', async () => { - setAgentLineageState({ agentActivityDisplayMode: 'full' }) - const { container } = await renderWorktreeList() - - expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false') - expect(childWorktreeCardPresent(container)).toBe(true) - - await click(agentChildDisclosure(container)!) - - expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true') - expect(container.textContent).toContain('CHILD_AGENT_PROMPT') - expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true') - expect(childWorktreeCardPresent(container)).toBe(true) - expect((mockStore.state.collapsedGroups as Set).size).toBe(0) - }) - it('[full mode] toggling CHILD WORKTREES still remounts the card but PRESERVES agent expansion (regression)', async () => { setAgentLineageState({ agentActivityDisplayMode: 'full' }) const { container, root } = await renderWorktreeList() diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-child-real-card.test.tsx deleted file mode 100644 index 82093a67ffc..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.lineage-child-real-card.test.tsx +++ /dev/null @@ -1,627 +0,0 @@ -// @vitest-environment happy-dom - -vi.mock('@/components/confirmation-dialog-context', () => ({ - useConfirmationDialog: () => vi.fn().mockResolvedValue(false) -})) - -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ProjectHeaderDragContract from '../drag/project-header-drag-contract' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { - FLUSH_CARD_MIN_CONTENT_INSET, - LINEAGE_CHILDREN_INLINE_OFFSET, - LINEAGE_IMMEDIATE_PARENT_STEP, - LINEAGE_NESTED_ROW_SURFACE_INSET, - WORKTREE_CARD_SURFACE_MARGIN -} from '../rows/indentation' - -import { revealMountedWorktreeElement } from '../navigation/mounted-row-reveal' - -globalThis.IS_REACT_ACT_ENVIRONMENT = true - -const mockStore = vi.hoisted(() => ({ - state: {} as Record, - setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(), - activateWorktreeFromSidebar: vi.fn(), - openModal: vi.fn(), - updateWorktreeMeta: vi.fn(), - fetchHostedReviewForBranch: vi.fn(), - fetchIssue: vi.fn(), - fetchLinearIssue: vi.fn(), - openTaskPage: vi.fn() -})) - -type WorktreeListComponent = React.ComponentType<{ - scrollOffsetRef: React.RefObject - scrollAnchorRef: React.RefObject -}> - -let WorktreeList: WorktreeListComponent - -function makeFolderWorkspacePathStatusMockState(): Record { - return { - fetchFolderWorkspacePathStatus: vi.fn(), - folderWorkspaces: [], - folderWorkspacePathStatuses: {}, - getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), - getFreshFolderWorkspacePathStatus: () => null - } -} - -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: Record) => unknown) => selector(mockStore.state), - { - getState: () => mockStore.state, - subscribe: () => () => {} - } - ) -})) - -vi.mock('@tanstack/react-virtual', () => ({ - defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => - Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), - measureElement: () => 32, - useVirtualizer: ({ count }: { count: number }) => ({ - elementsCache: new Map(), - getTotalSize: () => count * 96, - getVirtualItems: () => - Array.from({ length: count }, (_, index) => ({ - index, - key: `row-${index}`, - start: index * 96 - })), - measureElement: vi.fn(), - scrollToIndex: vi.fn() - }) -})) - -vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', - useVirtualizedScrollAnchor: vi.fn() -})) - -vi.mock('../drag/project-header-drag', () => ({ - useRepoHeaderDrag: () => ({ - state: { draggingRepoId: null, dropIndicatorY: null }, - onHandlePointerDown: vi.fn() - }) -})) - -vi.mock('../drag/project-header-drag-contract', async (importOriginal) => ({ - ...(await importOriginal()), - isRepoHeaderActionTarget: () => false -})) - -vi.mock('@/components/ui/hover-card', () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( - - ), - DropdownMenuSeparator: () =>
, - DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar -})) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) - -vi.mock('../../workspace-card/activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: () => null -})) - -vi.mock('../../workspace-card/activity/WorktreeCardAgents', () => ({ - default: ({ worktreeId }: { worktreeId: string }) => ( -
Agent row
- ), - SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' -})) - -vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({ - default: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', - WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' -})) - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/tmp/lineage-real-card', - displayName: 'lineage-real-card', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(args: { - id: string - displayName: string - branch: string - sortOrder: number - instanceId: string - overrides?: Partial -}): Worktree { - return { - id: args.id, - instanceId: args.instanceId, - repoId: 'repo-1', - path: `/tmp/lineage-real-card/${args.id}`, - displayName: args.displayName, - branch: args.branch, - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: args.sortOrder, - lastActivityAt: args.sortOrder, - ...args.overrides - } -} - -function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { - return { - worktreeId: worktree.id, - worktreeInstanceId: worktree.instanceId!, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId!, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - createdAt: 1 - } -} - -function makeHostedReview(overrides: Partial = {}): HostedReviewInfo { - return { - provider: 'gitlab', - number: 42, - title: 'Child GitLab MR', - state: 'open', - url: 'https://gitlab.com/acme/orca/-/merge_requests/42', - status: 'success', - updatedAt: '2026-06-09T00:00:00.000Z', - mergeable: 'MERGEABLE', - ...overrides - } -} - -function makeFolderWorkspacePathStatusState(): Record { - return { - fetchFolderWorkspacePathStatus: vi.fn(), - folderWorkspacePathStatuses: {}, - folderWorkspaces: [], - getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), - getFreshFolderWorkspacePathStatus: vi.fn(() => null) - } -} - -function setLineageState( - options: { deletingChild?: boolean; includeGrandchild?: boolean } = {} -): void { - const repo = makeRepo() - const parent = makeWorktree({ - id: 'parent', - instanceId: 'parent-instance', - displayName: 'lineage parent', - branch: 'parent-branch', - sortOrder: 20 - }) - const child = makeWorktree({ - id: 'child', - instanceId: 'child-instance', - displayName: 'lineage child', - branch: 'child-branch', - sortOrder: 10, - overrides: { - linkedGitLabMR: 42, - comment: 'Child handoff note' - } - }) - const grandchild = makeWorktree({ - id: 'grandchild', - instanceId: 'grandchild-instance', - displayName: 'lineage grandchild', - branch: 'grandchild-branch', - sortOrder: 5 - }) - const worktrees = options.includeGrandchild ? [parent, child, grandchild] : [parent, child] - const worktreeLineageById: Record = { - [child.id]: makeLineage(child, parent) - } - if (options.includeGrandchild) { - worktreeLineageById[grandchild.id] = makeLineage(grandchild, child) - } - mockStore.state = { - ...makeFolderWorkspacePathStatusMockState(), - activeModal: '', - activeView: 'terminal', - activeWorktreeId: null, - agentStatusByPaneKey: {}, - agentStatusEpoch: 0, - browserTabsByWorktree: {}, - clearPendingRevealWorktreeId: vi.fn(), - collapsedGroups: new Set(), - deleteStateByWorktreeId: options.deletingChild - ? { [child.id]: { isDeleting: true, error: null, canForceDelete: false } } - : {}, - detectedWorktreesByRepo: {}, - fetchHostedReviewForBranch: mockStore.fetchHostedReviewForBranch, - fetchIssue: mockStore.fetchIssue, - fetchLinearIssue: mockStore.fetchLinearIssue, - filterRepoIds: [], - ...makeFolderWorkspacePathStatusState(), - gitConflictOperationByWorktree: {}, - groupBy: 'none', - hideDefaultBranchWorkspace: false, - hostedReviewCache: { - 'local::repo-1::child-branch': { - data: makeHostedReview(), - fetchedAt: Date.now(), - linkedReviewHintKey: 'gitlab:42' - } - }, - issueCache: {}, - linearIssueCache: {}, - linearStatus: null, - migrationUnsupportedByPtyId: {}, - openModal: mockStore.openModal, - openSettingsPage: vi.fn(), - openSettingsTarget: null, - openTaskPage: mockStore.openTaskPage, - pendingRevealWorktree: null, - prCache: {}, - projectGroups: [], - ptyIdsByTabId: {}, - recordFeatureInteraction: vi.fn(), - remoteBranchConflictByWorktreeId: {}, - reorderRepos: vi.fn(), - reportVisibleGitHubPRRefreshCandidates: vi.fn(), - setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, - repos: [repo], - retainedAgentsByPaneKey: {}, - revealWorktreeInSidebar: vi.fn(), - runtimePaneTitlesByTabId: {}, - setFilterRepoIds: vi.fn(), - setHideDefaultBranchWorkspace: vi.fn(), - setRenamingWorktreeId: vi.fn(), - setShowSleepingWorkspaces: vi.fn(), - setSortBy: vi.fn(), - setWorktreesPinnedAndReveal: vi.fn(), - settings: null, - showSleepingWorkspaces: true, - sortBy: 'manual', - sortEpoch: 0, - sshConnectedGeneration: 0, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - unifiedTabsByWorktree: {}, - structuredChatSleptAtByPaneKey: {}, - terminalLayoutsByTabId: {}, - toggleCollapsedGroup: vi.fn(), - updateRepo: vi.fn(), - updateWorktreeMeta: mockStore.updateWorktreeMeta, - updateWorktreesMeta: vi.fn(), - workspaceHostScope: 'all', - workspacePortScan: null, - workspaceStatuses: [], - worktreeCardProperties: [ - 'status', - 'pr', - 'comment', - 'inline-agents' - ] satisfies WorktreeCardProperty[], - worktreeLineageById, - worktreesByRepo: { - [repo.id]: worktrees - } - } -} - -const mountedRoots: Root[] = [] - -async function renderWorktreeList(): Promise { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - await act(async () => { - root.render( - - ) - }) - return container -} - -function px(value: string): number { - return value === '' ? 0 : Number.parseFloat(value) -} - -function expectBoundaryStep(args: { - wrapper: HTMLElement - row: HTMLElement - surface: HTMLElement -}): void { - const effectiveStep = - px(args.wrapper.style.marginLeft) + - px(args.row.style.paddingLeft) + - WORKTREE_CARD_SURFACE_MARGIN + - px(args.surface.style.paddingLeft) - - expect(effectiveStep).toBe(LINEAGE_IMMEDIATE_PARENT_STEP) -} - -describe('WorktreeList real child WorktreeCard integration', () => { - beforeAll(async () => { - WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 60_000) - - beforeEach(() => { - vi.clearAllMocks() - setLineageState() - }) - - afterEach(async () => { - await act(async () => { - for (const root of mountedRoots.splice(0)) { - root.unmount() - } - }) - document.body.innerHTML = '' - }) - - it('renders GitLab MR metadata from a child through the real WorktreeCard path', async () => { - const container = await renderWorktreeList() - const childOption = container.querySelector('[id="worktree-list-option-all%3A%7Cchild"]') - - expect(childOption?.textContent).toContain('MR #42') - expect(childOption?.textContent).toContain('Child GitLab MR') - expect(childOption?.textContent).toContain('Child handoff note') - }) - - it('keeps expanded child cards in the parent title column', async () => { - mockStore.state.settings = { experimentalNewWorktreeCardStyle: true } - const container = await renderWorktreeList() - const childList = container.querySelector('[data-worktree-lineage-children]') - - expect(childList).not.toBeNull() - expect(childList!.style.marginLeft).toBe(`${LINEAGE_CHILDREN_INLINE_OFFSET}px`) - expect(childList!.style.width).toBe(`calc(100% - ${LINEAGE_CHILDREN_INLINE_OFFSET}px)`) - }) - - it.each([ - { newCardStyle: false, showStatus: false }, - { newCardStyle: false, showStatus: true }, - { newCardStyle: true, showStatus: false }, - { newCardStyle: true, showStatus: true } - ])('keeps lineage outside the status lane (%j)', async ({ newCardStyle, showStatus }) => { - setLineageState({ includeGrandchild: true }) - mockStore.state.settings = { experimentalNewWorktreeCardStyle: newCardStyle } - mockStore.state.worktreeCardProperties = showStatus ? ['status'] : [] - const container = await renderWorktreeList() - const wrappers = [ - ...container.querySelectorAll('[data-worktree-lineage-children]') - ] - const childRow = container.querySelector( - '[id="worktree-list-option-all%3A%7Cchild"]' - ) - const grandchildRow = container.querySelector( - '[id="worktree-list-option-all%3A%7Cgrandchild"]' - ) - const childSurface = childRow?.querySelector('[data-worktree-card-surface="true"]') - const grandchildSurface = grandchildRow?.querySelector( - '[data-worktree-card-surface="true"]' - ) - - expect(wrappers).toHaveLength(2) - expect(childRow).not.toBeNull() - expect(grandchildRow).not.toBeNull() - expect(childSurface).not.toBeNull() - expect(grandchildSurface).not.toBeNull() - expect(px(childRow!.style.paddingLeft)).toBe(LINEAGE_NESTED_ROW_SURFACE_INSET) - expect(px(grandchildRow!.style.paddingLeft)).toBe(LINEAGE_NESTED_ROW_SURFACE_INSET) - expect(px(childSurface!.style.paddingLeft)).toBe(FLUSH_CARD_MIN_CONTENT_INSET) - expect(px(grandchildSurface!.style.paddingLeft)).toBe(FLUSH_CARD_MIN_CONTENT_INSET) - for (const wrapper of wrappers) { - expect(wrapper.parentElement?.dataset.worktreeCardSurface).toBe('true') - expect(wrapper.closest('[data-worktree-card-parent-content]')).toBeNull() - } - - expectBoundaryStep({ - wrapper: wrappers[0]!, - row: childRow!, - surface: childSurface! - }) - expectBoundaryStep({ - wrapper: wrappers[1]!, - row: grandchildRow!, - surface: grandchildSurface! - }) - }) - - it.each([ - { newStyle: false, deleting: false }, - { newStyle: true, deleting: false }, - { newStyle: true, deleting: true } - ])('reveals complete real-card segments %j', async ({ newStyle, deleting }) => { - setLineageState({ includeGrandchild: true, deletingChild: deleting }) - mockStore.state.settings = { experimentalNewWorktreeCardStyle: newStyle } - const container = await renderWorktreeList() - const parent = container.querySelector( - '[role="option"][data-worktree-id="parent"]' - )! - const child = container.querySelector('[role="option"][data-worktree-id="child"]')! - const boundaries = container.querySelectorAll('[data-worktree-lineage-children]') - Object.defineProperty(container, 'clientHeight', { value: 673 }) - container.getBoundingClientRect = () => new DOMRect(0, 0, 300, 673) - parent.getBoundingClientRect = () => new DOMRect(0, 200, 300, 11800) - child.getBoundingClientRect = () => new DOMRect(0, 900, 300, 11100) - boundaries[0]!.getBoundingClientRect = () => new DOMRect(0, 320, 300, 11680) - boundaries[1]!.getBoundingClientRect = () => new DOMRect(0, 1020, 300, 10980) - container.scrollTo = vi.fn() - - expect(revealMountedWorktreeElement(container, 'parent', 'auto', parent.id)).toBe(parent) - expect(container.scrollTo).not.toHaveBeenCalled() - expect(revealMountedWorktreeElement(container, 'child', 'auto', child.id)).toBe(child) - expect(container.scrollTo).toHaveBeenCalledWith({ top: 347, behavior: 'auto' }) - const leaf = container.querySelector('[data-worktree-id="grandchild"]')! - leaf.getBoundingClientRect = () => new DOMRect(0, 1005, 300, 55) - expect(revealMountedWorktreeElement(container, 'grandchild', 'auto', leaf.id)).toBe(leaf) - expect(container.scrollTo).toHaveBeenLastCalledWith({ top: 387, behavior: 'auto' }) - expect(parent.getBoundingClientRect().height).toBe(11800) - if (newStyle && !deleting) { - expect(parent.querySelector('[data-worktree-sleeping-dim]')).not.toBeNull() - } - if (deleting) { - expect(child.querySelector('[aria-busy=true]')).not.toBeNull() - } - expect(boundaries[0]!.parentElement?.lastElementChild).toBe(boundaries[0]) - }) - - it('double-clicking a nested child opens edit metadata for the child only', async () => { - const container = await renderWorktreeList() - const childCard = container.querySelector( - '[id="worktree-list-option-all%3A%7Cchild"] [data-worktree-card-surface="true"]' - ) - - expect(childCard).not.toBeNull() - await act(async () => { - childCard!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) - }) - - expect(mockStore.openModal).toHaveBeenCalledTimes(1) - expect(mockStore.openModal).toHaveBeenCalledWith( - 'edit-meta', - expect.objectContaining({ - worktreeId: 'child', - currentDisplayName: 'lineage child', - currentComment: 'Child handoff note' - }) - ) - expect(mockStore.openModal).not.toHaveBeenCalledWith( - 'edit-meta', - expect.objectContaining({ worktreeId: 'parent' }) - ) - }) - - it.each([ - { pressed: 'option', worktreeId: 'parent' }, - { pressed: 'option', worktreeId: 'child' }, - { pressed: 'option', worktreeId: 'folder:folder-workspace' }, - { pressed: 'card', worktreeId: 'parent' }, - { pressed: 'card', worktreeId: 'child' }, - { pressed: 'card', worktreeId: 'folder:folder-workspace' } - ])('activating the $worktreeId $pressed activates it once', async ({ pressed, worktreeId }) => { - mockStore.state.projectGroups = [ - { - id: 'folder-group', - name: 'Folder group', - parentPath: '/tmp/lineage-real-card-folder', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - ] satisfies ProjectGroup[] - mockStore.state.folderWorkspaces = [ - { - id: 'folder-workspace', - projectGroupId: 'folder-group', - name: 'folder workspace', - folderPath: '/tmp/lineage-real-card-folder', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } - ] satisfies FolderWorkspace[] - const container = await renderWorktreeList() - const option = container.querySelector( - `[role="option"][data-worktree-id="${worktreeId}"]` - ) - const target = - pressed === 'option' - ? option - : option?.querySelector('[data-worktree-card-surface="true"]') - - expect(target).not.toBeNull() - await act(async () => { - target!.click() - }) - - if (pressed === 'option' && worktreeId === 'folder:folder-workspace') { - container.getBoundingClientRect = () => new DOMRect(0, 0, 300, 673) - Object.defineProperty(container, 'clientHeight', { value: 673 }) - option!.getBoundingClientRect = () => new DOMRect(0, 1005, 300, 55) - container.scrollTo = vi.fn() - expect(revealMountedWorktreeElement(container, worktreeId, 'auto', option!.id)).toBe(option) - expect(container.scrollTo).toHaveBeenCalledWith({ top: 387, behavior: 'auto' }) - } - - expect(mockStore.activateWorktreeFromSidebar).toHaveBeenCalledTimes(1) - expect(mockStore.activateWorktreeFromSidebar).toHaveBeenCalledWith(worktreeId, 'local') - }) - - it('does not activate a nested child while it is deleting', async () => { - setLineageState({ deletingChild: true }) - const container = await renderWorktreeList() - const childCard = container.querySelector( - '[id="worktree-list-option-all%3A%7Cchild"] [data-worktree-card-surface="true"]' - ) - - expect(childCard?.textContent).toContain('Deleting') - await act(async () => { - childCard!.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(mockStore.activateWorktreeFromSidebar).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.status-lane-lineage-drop.test.tsx deleted file mode 100644 index 5162f51580d..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.status-lane-lineage-drop.test.tsx +++ /dev/null @@ -1,460 +0,0 @@ -// @vitest-environment happy-dom - -vi.mock('@/components/confirmation-dialog-context', () => ({ - useConfirmationDialog: () => vi.fn().mockResolvedValue(false) -})) - -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ProjectHeaderDragContract from '../drag/project-header-drag-contract' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import type { WorkspaceStatus, Worktree } from '../../../../../../shared/worktree/types' -import type { WorktreeMetaBatchUpdate } from '../../../../store/slices/worktree-helpers' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../../shared/workspace-status-defaults' -import { - WORKSPACE_STATUS_DRAG_IDS_TYPE, - WORKSPACE_STATUS_DRAG_TYPE -} from '../../workspace-status/presentation/workspace-status-drag-data' - -globalThis.IS_REACT_ACT_ENVIRONMENT = true - -const mockStore = vi.hoisted(() => ({ - state: {} as Record, - setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(), - activateWorktreeFromSidebar: vi.fn(), - openModal: vi.fn(), - updateWorktreeMeta: vi.fn(), - updateWorktreesMeta: vi.fn(), - setSortBy: vi.fn(), - fetchHostedReviewForBranch: vi.fn(), - fetchIssue: vi.fn(), - fetchLinearIssue: vi.fn(), - openTaskPage: vi.fn() -})) - -type WorktreeListComponent = React.ComponentType<{ - scrollOffsetRef: React.RefObject - scrollAnchorRef: React.RefObject -}> - -let WorktreeList: WorktreeListComponent - -function makeFolderWorkspacePathStatusMockState(): Record { - return { - fetchFolderWorkspacePathStatus: vi.fn(), - folderWorkspaces: [], - folderWorkspacePathStatuses: {}, - getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), - getFreshFolderWorkspacePathStatus: () => null - } -} - -vi.mock('@/store', () => ({ - useAppStore: Object.assign( - (selector: (state: Record) => unknown) => selector(mockStore.state), - { - getState: () => mockStore.state, - subscribe: () => () => {} - } - ) -})) - -vi.mock('@tanstack/react-virtual', () => ({ - defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => - Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), - measureElement: () => 32, - useVirtualizer: ({ count }: { count: number }) => ({ - elementsCache: new Map(), - getTotalSize: () => count * 96, - getVirtualItems: () => - Array.from({ length: count }, (_, index) => ({ - index, - key: `row-${index}`, - start: index * 96 - })), - measureElement: vi.fn(), - scrollToIndex: vi.fn() - }) -})) - -vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', - useVirtualizedScrollAnchor: vi.fn() -})) - -vi.mock('../drag/project-header-drag', () => ({ - useRepoHeaderDrag: () => ({ - state: { draggingRepoId: null, dropIndicatorY: null }, - onHandlePointerDown: vi.fn() - }) -})) - -vi.mock('../drag/project-header-drag-contract', async (importOriginal) => ({ - ...(await importOriginal()), - isRepoHeaderActionTarget: () => false -})) - -vi.mock('@/components/ui/hover-card', () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( - - ), - DropdownMenuSeparator: () =>
, - DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar -})) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) - -vi.mock('../../workspace-card/activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: () => null -})) - -vi.mock('../../workspace-card/activity/WorktreeCardAgents', () => ({ - default: ({ worktreeId }: { worktreeId: string }) => ( -
Agent row
- ), - SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' -})) - -vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({ - default: ({ children }: { children: ReactNode }) => <>{children}, - WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', - WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' -})) - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/tmp/status-lane-lineage', - displayName: 'status-lane-lineage', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(args: { - id: string - displayName: string - branch: string - sortOrder: number - instanceId: string - workspaceStatus: WorkspaceStatus -}): Worktree { - return { - id: args.id, - instanceId: args.instanceId, - repoId: 'repo-1', - path: `/tmp/status-lane-lineage/${args.id}`, - displayName: args.displayName, - branch: args.branch, - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: args.sortOrder, - lastActivityAt: args.sortOrder, - workspaceStatus: args.workspaceStatus - } -} - -function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { - return { - worktreeId: worktree.id, - worktreeInstanceId: worktree.instanceId!, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId!, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - createdAt: 1 - } -} - -function makeFolderWorkspacePathStatusState(): Record { - return { - fetchFolderWorkspacePathStatus: vi.fn(), - folderWorkspacePathStatuses: {}, - folderWorkspaces: [], - getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), - getFreshFolderWorkspacePathStatus: vi.fn(() => null) - } -} - -function setStatusLaneState(): void { - const repo = makeRepo() - const parent = makeWorktree({ - id: 'parent', - instanceId: 'parent-instance', - displayName: 'lineage parent', - branch: 'parent-branch', - sortOrder: 30, - workspaceStatus: 'in-progress' - }) - const child = makeWorktree({ - id: 'child', - instanceId: 'child-instance', - displayName: 'lineage child', - branch: 'child-branch', - sortOrder: 20, - workspaceStatus: 'in-progress' - }) - const bystander = makeWorktree({ - id: 'bystander', - instanceId: 'bystander-instance', - displayName: 'bystander', - branch: 'bystander-branch', - sortOrder: 10, - workspaceStatus: 'todo' - }) - mockStore.state = { - ...makeFolderWorkspacePathStatusMockState(), - activeModal: '', - activeView: 'terminal', - activeWorktreeId: null, - agentStatusByPaneKey: {}, - agentStatusEpoch: 0, - browserTabsByWorktree: {}, - clearPendingRevealWorktreeId: vi.fn(), - collapsedGroups: new Set(), - deleteStateByWorktreeId: {}, - detectedWorktreesByRepo: {}, - fetchHostedReviewForBranch: mockStore.fetchHostedReviewForBranch, - fetchIssue: mockStore.fetchIssue, - fetchLinearIssue: mockStore.fetchLinearIssue, - filterRepoIds: [], - ...makeFolderWorkspacePathStatusState(), - gitConflictOperationByWorktree: {}, - groupBy: 'workspace-status', - hideDefaultBranchWorkspace: false, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - linearStatus: null, - migrationUnsupportedByPtyId: {}, - openModal: mockStore.openModal, - openSettingsPage: vi.fn(), - openSettingsTarget: null, - openTaskPage: mockStore.openTaskPage, - pendingRevealWorktree: null, - prCache: {}, - projectGroups: [], - ptyIdsByTabId: {}, - recordFeatureInteraction: vi.fn(), - remoteBranchConflictByWorktreeId: {}, - reorderRepos: vi.fn(), - reportVisibleGitHubPRRefreshCandidates: vi.fn(), - setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, - repos: [repo], - retainedAgentsByPaneKey: {}, - revealWorktreeInSidebar: vi.fn(), - runtimePaneTitlesByTabId: {}, - setFilterRepoIds: vi.fn(), - setHideDefaultBranchWorkspace: vi.fn(), - setRenamingWorktreeId: vi.fn(), - setShowSleepingWorkspaces: vi.fn(), - setSortBy: mockStore.setSortBy, - setWorktreesPinnedAndReveal: vi.fn(), - settings: null, - showSleepingWorkspaces: true, - sortBy: 'manual', - sortEpoch: 0, - sshConnectedGeneration: 0, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - unifiedTabsByWorktree: {}, - structuredChatSleptAtByPaneKey: {}, - terminalLayoutsByTabId: {}, - toggleCollapsedGroup: vi.fn(), - updateRepo: vi.fn(), - updateWorktreeMeta: mockStore.updateWorktreeMeta, - updateWorktreesMeta: mockStore.updateWorktreesMeta, - workspaceHostScope: 'all', - workspacePortScan: null, - workspaceStatuses: [...DEFAULT_WORKSPACE_STATUSES], - worktreeCardProperties: [ - 'status', - 'pr', - 'comment', - 'inline-agents' - ] satisfies WorktreeCardProperty[], - worktreeLineageById: { [child.id]: makeLineage(child, parent) }, - worktreesByRepo: { [repo.id]: [parent, child, bystander] } - } -} - -const mountedRoots: Root[] = [] - -async function renderWorktreeList(): Promise { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - await act(async () => { - root.render( - - ) - }) - return container -} - -function makeWorktreeIdDataTransfer(worktreeIds: readonly string[]): DataTransfer { - const [firstId] = worktreeIds - const data: Record = { - [WORKSPACE_STATUS_DRAG_TYPE]: firstId ?? '', - [WORKSPACE_STATUS_DRAG_IDS_TYPE]: JSON.stringify(worktreeIds), - 'text/plain': firstId ?? '' - } - return { - effectAllowed: 'move', - types: Object.keys(data), - getData: (type: string) => data[type] ?? '', - setData: () => {} - } as unknown as DataTransfer -} - -function findStatusLaneHeader(container: HTMLElement, status: WorkspaceStatus): HTMLElement { - const header = container.querySelector( - `[data-workspace-status-drop-target][data-workspace-status="${status}"]` - ) - if (!header) { - throw new Error(`No status lane header rendered for "${status}"`) - } - return header -} - -async function dropWorktreesOnStatusLane( - header: HTMLElement, - worktreeIds: readonly string[], - dataTransfer: DataTransfer = makeWorktreeIdDataTransfer(worktreeIds) -): Promise { - const event = new Event('drop', { bubbles: true, cancelable: true }) - Object.defineProperty(event, 'dataTransfer', { - value: dataTransfer - }) - await act(async () => { - header.dispatchEvent(event) - }) -} - -function committedStatusUpdates(): Map> { - expect(mockStore.updateWorktreesMeta).toHaveBeenCalledTimes(1) - const input = mockStore.updateWorktreesMeta.mock.calls[0]![0] as - | readonly WorktreeMetaBatchUpdate[] - | ReadonlyMap> - if ('get' in input) { - return new Map(input) - } - return new Map(input.map((entry) => [entry.worktreeId, entry.updates])) -} - -describe('WorktreeList status-lane drop carries visible lineage children (#9083)', () => { - beforeAll(async () => { - WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 60_000) - - beforeEach(() => { - vi.clearAllMocks() - setStatusLaneState() - }) - - afterEach(async () => { - await act(async () => { - for (const root of mountedRoots.splice(0)) { - root.unmount() - } - }) - document.body.innerHTML = '' - }) - - it('moves a dragged parent and its visible lineage child to the dropped lane', async () => { - const container = await renderWorktreeList() - const todoHeader = findStatusLaneHeader(container, 'todo') - - // Native drag carries only the parent ID; the wrapper must add its lineage children. - await dropWorktreesOnStatusLane(todoHeader, ['parent']) - - const updates = committedStatusUpdates() - expect(updates.get('parent')).toEqual({ workspaceStatus: 'todo' }) - expect(updates.get('child')).toEqual({ workspaceStatus: 'todo' }) - }) - - it('ignores a hybrid file drag with a workspace id in plain text', async () => { - const container = await renderWorktreeList() - const todoHeader = findStatusLaneHeader(container, 'todo') - const hybridTransfer = { - types: ['Files', 'text/plain'], - files: [{ name: 'notes.txt' }], - getData: (type: string) => (type === 'text/plain' ? 'parent' : '') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The drop reader uses only types and getData from this fixture. - const dataTransfer = hybridTransfer as unknown as DataTransfer - - await dropWorktreesOnStatusLane(todoHeader, ['parent'], dataTransfer) - - expect(mockStore.updateWorktreeMeta).not.toHaveBeenCalled() - expect(mockStore.updateWorktreesMeta).not.toHaveBeenCalled() - }) - - it('leaves worktrees in other lanes untouched', async () => { - const container = await renderWorktreeList() - const todoHeader = findStatusLaneHeader(container, 'todo') - - await dropWorktreesOnStatusLane(todoHeader, ['parent']) - - const updates = committedStatusUpdates() - expect(updates.has('bystander')).toBe(false) - expect(updates.size).toBe(2) - }) - - it('commits only itself when a childless worktree is dropped onto another lane', async () => { - const container = await renderWorktreeList() - const inProgressHeader = findStatusLaneHeader(container, 'in-progress') - - await dropWorktreesOnStatusLane(inProgressHeader, ['bystander']) - - const updates = committedStatusUpdates() - expect(updates.get('bystander')).toEqual({ - workspaceStatus: 'in-progress' - }) - expect(updates.has('parent')).toBe(false) - expect(updates.has('child')).toBe(false) - expect(updates.size).toBe(1) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/agent-send-picker-collapse.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/agent-send-picker-collapse.test.tsx deleted file mode 100644 index 9b55c27c969..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/listing/agent-send-picker-collapse.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, renderHook } from '@testing-library/react' -import { afterEach, expect, it } from 'vitest' -import { useAppStore } from '@/store/index' -import { makeRepo, makeWorktree } from './worktree-list-lineage-card-test-fixtures' -import { useEffectiveCollapsedGroups } from './use-collapsed-groups' -import { useSidebarSectionRows } from './use-section-rows' -import { getWorktreeLineageGroupKey } from '../../../../../../shared/sidebar-list/group-keys' -import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import type { WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' - -afterEach(cleanup) -const projectGrouping = { projects: [], projectHostSetups: [] } -const settings = useAppStore.getState().settings -const workspaceStatuses = cloneDefaultWorkspaceStatuses() -function setup(hostId: ExecutionHostId) { - const localRepo = makeRepo() - const remoteRepo = { ...localRepo, id: 'remote-repo', executionHostId: hostId } - const repoMap = new Map([ - [localRepo.id, localRepo], - [remoteRepo.id, remoteRepo] - ]) - const local = { - ...makeWorktree({ - id: 'local', - displayName: 'Local', - branch: 'local', - sortOrder: 0, - instanceId: 'local-instance' - }), - hostId: 'local' as const - } - const remote = { - ...local, - id: 'remote', - instanceId: 'remote-instance', - repoId: remoteRepo.id, - hostId - } - return { localRepo, remoteRepo, repoMap, local, remote } -} - -function sections( - hostId: ExecutionHostId, - groupBy: WorktreeGroupBy, - worktrees: Worktree[], - collapsedGroups: Set, - lineageById: Record = {} -) { - const { localRepo, remoteRepo, repoMap } = setup(hostId) - return renderHook(() => - useSidebarSectionRows({ - groupBy, - sortBy: 'smart', - projectOrderBy: 'manual', - pinnedDisplayPolicy: 'single-location', - defaultHostId: 'local', - worktrees, - repos: [localRepo, remoteRepo], - repoMap, - worktreeMap: new Map(worktrees.map((worktree) => [worktree.id, worktree])), - worktreeLineageById: lineageById, - prCache: null, - settings, - workspaceStatuses, - effectiveCollapsedGroups: collapsedGroups, - projectGrouping, - visibleReposForRows: [localRepo, remoteRepo], - visibleProjectGroupsForRows: [], - visibleFolderWorkspacesForRows: [], - importedWorktreesByRepo: new Map(), - newExternalWorktreesInboxByRepo: new Map(), - filterRepoIds: [], - visibleWorkspaceHostIds: ['local', hostId], - workspaceHostScope: 'all' - }) - ).result.current.sectionRows -} - -for (const hostId of ['ssh:builder', 'runtime:builder'] as const) { - it(`${hostId}: agent picker should expose a target within a collapsed host`, () => { - const { local, remote, repoMap } = setup(hostId) - const worktrees = [local, remote] - const collapsedGroups = new Set([`host:${hostId}`, 'host:local']) - const { result } = renderHook(() => - useEffectiveCollapsedGroups({ - hostScopedGroups: true, - collapsedGroups, - agentSendTargetWorktreeId: remote.id, - groupBy: 'none', - pinnedDisplayPolicy: 'single-location', - worktrees, - visibleWorktrees: worktrees, - repoMap, - worktreeMap: new Map(worktrees.map((worktree) => [worktree.id, worktree])), - worktreeLineageById: {}, - prCache: null, - workspaceStatuses, - projectGroups: [], - projectGrouping, - folderWorkspaces: [], - defaultHostId: 'local' - }) - ) - expect([...collapsedGroups]).toEqual([`host:${hostId}`, 'host:local']) - expect(result.current.has('host:local')).toBe(true) - const rows = sections(hostId, 'none', worktrees, result.current) - expect(rows.some((row) => row.type === 'host-header' && row.hostId === hostId)).toBe(true) - expect(rows.some((row) => row.type === 'item' && row.worktree.id === remote.id)).toBe(true) - }) -} - -for (const hostId of ['local', 'ssh:builder', 'runtime:builder'] as const) { - it(`${hostId}: agent picker should expose child under a collapsed parent`, () => { - const { local, remote, repoMap } = setup(hostId) - const parent = hostId === 'local' ? local : remote - if (!parent.instanceId) { - throw new Error('Fixture parent instance missing') - } - const child = { ...parent, id: 'child', instanceId: 'child-instance', sortOrder: 1 } - const worktrees = [parent, child] - const lineage: WorktreeLineage = { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - createdAt: 1 - } - const worktreeLineageById = { [child.id]: lineage } - const worktreeMap = new Map(worktrees.map((worktree) => [worktree.id, worktree])) - const collapsedGroups = new Set([getWorktreeLineageGroupKey(parent)]) - expect( - sections(hostId, 'none', worktrees, collapsedGroups, worktreeLineageById).some( - (row) => row.type === 'item' && row.worktree.id === child.id - ) - ).toBe(false) - const { result } = renderHook(() => - useEffectiveCollapsedGroups({ - hostScopedGroups: true, - collapsedGroups, - agentSendTargetWorktreeId: child.id, - groupBy: 'none', - pinnedDisplayPolicy: 'single-location', - worktrees, - visibleWorktrees: worktrees, - repoMap, - worktreeMap, - worktreeLineageById, - prCache: null, - workspaceStatuses, - projectGroups: [], - projectGrouping, - folderWorkspaces: [], - defaultHostId: 'local' - }) - ) - expect(collapsedGroups.has(getWorktreeLineageGroupKey(parent))).toBe(true) - const after = sections(hostId, 'none', worktrees, result.current, worktreeLineageById) - expect(after.some((row) => row.type === 'item' && row.worktree.id === child.id)).toBe(true) - }) -} - -it.each([true, false])('reveals the target with pinned ancestors visible=%s', (visible) => { - const { local, remote, repoMap } = setup('ssh:builder') - const root = { ...remote, instanceId: 'root-instance', isPinned: true } - const parent = { ...remote, id: 'parent', instanceId: 'parent-instance', isPinned: false } - const child = { ...remote, id: 'child', instanceId: 'child-instance', isPinned: false } - const worktrees = [local, root, parent, child] - const visibleWorktrees = visible ? worktrees : [local, child] - const lineage = (worktree: typeof child, ancestor: typeof root): WorktreeLineage => ({ - worktreeId: worktree.id, - worktreeInstanceId: worktree.instanceId, - parentWorktreeId: ancestor.id, - parentWorktreeInstanceId: ancestor.instanceId, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - createdAt: 1 - }) - const worktreeLineageById = { - [parent.id]: lineage(parent, root), - [child.id]: lineage(child, parent) - } - const collapsedGroups = new Set([ - 'host:ssh:builder', - 'lineage:ssh:builder|remote', - 'lineage:ssh:builder|parent', - 'all:host:ssh:builder', - 'all', - 'pinned:host:ssh:builder', - 'host:local', - 'lineage:local|parent' - ]) - const initialProps: { targetId: string | null } = { targetId: child.id } - const { result, rerender } = renderHook( - ({ targetId }: { targetId: string | null }) => - useEffectiveCollapsedGroups({ - hostScopedGroups: true, - collapsedGroups, - agentSendTargetWorktreeId: targetId, - groupBy: 'none', - pinnedDisplayPolicy: 'single-location', - worktrees, - visibleWorktrees, - repoMap, - worktreeMap: new Map(worktrees.map((worktree) => [worktree.id, worktree])), - worktreeLineageById, - prCache: null, - workspaceStatuses, - projectGroups: [], - projectGrouping, - folderWorkspaces: [], - defaultHostId: 'local' - }), - { initialProps } - ) - expect([...result.current].sort()).toEqual( - [ - visible ? 'all:host:ssh:builder' : 'pinned:host:ssh:builder', - 'all', - 'host:local', - 'lineage:local|parent', - ...(visible ? [] : ['lineage:ssh:builder|remote', 'lineage:ssh:builder|parent']) - ].sort() - ) - expect( - sections('ssh:builder', 'none', visibleWorktrees, result.current, worktreeLineageById).some( - (row) => row.type === 'item' && row.worktree.id === child.id - ) - ).toBe(true) - expect(collapsedGroups.size).toBe(8) - rerender({ targetId: null }) - expect(result.current).toBe(collapsedGroups) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx index 61b8ada3030..877885a3f2b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx @@ -11,20 +11,18 @@ import { worktree, visibilityArgs, sectionArgs, - sidebarCorpus, countArrayEntries, countMapEntries } from './host-status-test-fixtures' import { useProjectHeaderHostContexts } from '../viewport/use-project-header-host-contexts' import { useVisibleSidebarWorktrees } from './use-visible-worktrees' import { useSidebarSectionRows } from './use-section-rows' -import { useFolderWorkspacePathStatusRows } from './use-folder-path-statuses' -import { createProjectGroup } from '../../../../../../shared/project-groups' + import type * as FolderStatus from '@/store/folder-workspaces/folder-path-status' import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' import { useSidebarHostVisibleScope } from './use-host-visible-scope' import { makeFolderWorkspace } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' + import type * as SshGate from '@/lib/repo-ssh-connection' import type * as DeviceVisibility from '../../../../../../shared/workspace-creator-visibility' import type * as Registry from '../../../../../../shared/execution-host-registry' @@ -283,351 +281,6 @@ it('does zero device scans while off and preserves equal identities while on, in expect(counts.devices).toBe(off) }) -it('keeps equal host options and health-only changes out of the workspace row plan', () => { - const args = sectionArgs() - let renders = 0 - const { result } = renderHook(() => { - renders++ - return useSidebarSectionRows(args) - }) - const first = result.current - const before = renders - const plans = counts.plan - for (let flip = 0; flip < 10; flip++) { - act(() => publish('B', flip + 2, { status: { ...status('B'), liveTabCount: flip } })) - } - act(() => publish('A', 2, { status: { ...status('A'), liveTabCount: 99 } })) - expect(renders).toBe(before) - expect(result.current).toBe(first) - expect(counts.plan).toBe(plans) - const builds = counts.registry - act(() => useAppStore.setState({ sshStateByEnvironment: new Map() })) - expect(counts.registry).toBe(builds) - act(() => publish('A', 3, { verification: 'checking', transport: 'connecting' })) - expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')?.health).toBe( - 'connecting' - ) - expect(counts.plan).toBe(plans) - expect(result.current.occurrencePlan).toBe(first.occurrencePlan) - act(() => - useAppStore.setState({ - runtimeEnvironments: [environment('A'), { ...environment('B'), name: 'Renamed' }] - }) - ) - expect(result.current.hostOptions.find((host) => host.id === 'runtime:B')?.label).toBe('Renamed') - expect(counts.plan).toBe(plans + 1) - act(() => - publish('A', 4, { - status: { ...status('A'), capabilities: ['new-capability'], hostPlatform: 'linux' } - }) - ) - expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')).toMatchObject({ - capabilities: ['new-capability'], - platform: 'linux' - }) - act(() => - useAppStore.setState({ - runtimeEnvironments: [], - runtimeStatusByEnvironmentId: new Map([ - ['status-only', { status: status('status-only'), checkedAt: 1 }] - ]) - }) - ) - expect(result.current.hostOptions.map((host) => host.id)).toContain('runtime:status-only') - expect(result.current.hostOptions.map((host) => host.id)).not.toContain('runtime:B') -}) - -it('keeps compatibility, blocked guidance, aliases and diagnostic equality live in the host projection', () => { - const control = { - state: 'ready' as const, - pendingRequestCount: 0, - subscriptionCount: 0, - reconnectAttempt: 0, - lastConnectedAt: 1, - lastClose: null, - lastError: null, - lastRttMs: 1 - } - publish('A', 2, { remoteControl: control }) - const args = sectionArgs() - let renders = 0 - const { result } = renderHook(() => { - renders++ - return useSidebarSectionRows(args) - }) - const before = renders - const options = result.current.hostOptions - act(() => - publish('A', 3, { - remoteControl: { ...control, lastRttMs: 99, pendingRequestCount: 10, subscriptionCount: 5 } - }) - ) - expect(renders).toBe(before) - expect(result.current.hostOptions).toBe(options) - act(() => publish('A', 4, { verification: 'blocked', blockedReason: 'access-revoked' })) - expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')).toMatchObject({ - health: 'blocked', - blockedReason: 'access-revoked' - }) - act(() => publish('A', 5, { status: { ...status('A'), runtimeProtocolVersion: 0 } })) - expect( - result.current.hostOptions.find((host) => host.id === 'runtime:A')?.compatibility - ).toMatchObject({ kind: 'blocked', reason: 'server-too-old' }) - act(() => - useAppStore.setState({ - sshTargetLabels: new Map([['managed', 'My machine']]), - runtimeEnvironments: [ - { - ...environment('A'), - orcadDeployment: { - sshTargetId: 'managed', - sshTargetGeneration: 1, - localPort: 7001, - remotePort: 7002 - } - } - ] - }) - ) - expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')).toMatchObject({ - label: 'My machine', - aliasHostIds: ['ssh:managed'] - }) - act(() => - useAppStore.setState({ - sshConnectionStates: new Map([ - [ - 'managed', - { - ...ssh('connected', 'managed'), - managedServer: { kind: 'relay', reason: 'orcad_unavailable' } - } - ] - ]) - }) - ) - expect(result.current.hostOptions.find((host) => host.id === 'ssh:managed')).toMatchObject({ - aliasHostIds: ['runtime:A'] - }) -}) - -it.each([ - { repos: 10, hosts: 10 }, - { repos: 100, hosts: 10 }, - { repos: 10, hosts: 100 }, - { repos: 100, hosts: 100 } -])( - 'bounds one sidebar publication workload with $repos repos, $hosts hosts and three workspaces per repo', - ({ repos: repoCount, hosts: hostCount }) => { - const args = sidebarCorpus(repoCount, hostCount) - const dependencyCount = hostCount === 100 ? repoCount : 1 - const projectGroups: ProjectGroup[] = Array.from({ length: repoCount }, (_, index) => ({ - ...createProjectGroup({ - name: `Group ${index}`, - parentPath: `/folders/${index}`, - createdFrom: 'folder-scan', - tabOrder: index, - now: 1 - }), - id: `group-${index}`, - executionHostId: 'runtime:host-0', - connectionId: `target-${index % dependencyCount}` - })) - projectGroups.push({ - ...projectGroups[0]!, - id: 'nested', - parentPath: null, - parentGroupId: 'group-0' - }) - const folderWorkspaces = Array.from({ length: repoCount }, (_, index) => - makeFolderWorkspace({ - id: `folder-${index}`, - projectGroupId: 'group-0', - executionHostId: 'runtime:host-0', - folderPath: `/folders/0/${index}` - }) - ) - args.repos[0] = { ...args.repos[0]!, projectGroupId: 'nested' } - args.repoMap.set(args.repos[0]!.id, args.repos[0]!) - useAppStore.setState({ repos: args.repos, projectGroups, folderWorkspaces }) - // Keep requests pending: count production discovery/action snapshots without settlement writes. - const callRuntimeRpc = vi.fn(() => new Promise(() => {})) - runtimeRpc.callRuntimeRpc.mockImplementation(callRuntimeRpc) - const fetch = vi.fn(useAppStore.getState().fetchFolderWorkspacePathStatus) - useAppStore.setState({ fetchFolderWorkspacePathStatus: fetch }) - const folderArgs = { - allRepoIds: args.repos.map((repo) => repo.id), - repoMap: args.repoMap, - projectGroups, - folderWorkspaces - } - const visibleArgs = { - ...visibilityArgs, - repoMap: args.repoMap, - sortedIds: args.worktrees.map((item) => item.id), - filterState: { ...visibilityArgs.filterState, hideWorkspacesFromOtherDevices: true } - } - let renders = 0 - const { result } = renderHook(() => { - renders++ - const visible = useVisibleSidebarWorktrees(visibleArgs) - const section = useSidebarSectionRows({ ...args, worktrees: visible.visibleWorktrees }) - return { - visible, - section, - folder: useFolderWorkspacePathStatusRows(folderArgs), - contexts: useProjectHeaderHostContexts({ - rows: section.sectionRows, - hosts: section.hostOptions, - repoMap: args.repoMap - }) - } - }) - const before = { ...counts } - expect(counts.dependencies).toBe(2 * repoCount) - expect(counts.dependencyEntries).toBeGreaterThan(2 * repoCount * repoCount) - expect(counts.dependencyEntries).toBeLessThanOrEqual( - 2 * repoCount * (5 * projectGroups.length + 2 * repoCount) - ) - expect(fetch).toHaveBeenCalledTimes(2 * repoCount) - expect( - fetch.mock.calls.every( - ([, options]) => options.force && options.runtimeEnvironmentId === 'host-0' - ) - ).toBe(true) - const first = result.current - const settledRenders = renders - const fleet = hostCount + 1 - expect(first.visible.visibleWorktrees).toHaveLength(3 * repoCount) - expect(first.section.sectionRows).toHaveLength(repoCount) - expect(first.contexts.size).toBe(repoCount) - for (let sequence = 2; sequence < 12; sequence++) { - act(() => publish('B', sequence, { status: { ...status('B'), liveTabCount: sequence } })) - } - expect(renders).toBe(settledRenders) - expect(result.current).toBe(first) - // Expensive verdicts must not grow with targets owned by unchanged hosts. - expect(counts.gates - before.gates).toBe(0) - expect(counts.devices - before.devices).toBe(10) - expect(counts.deviceEntries - before.deviceEntries).toBe(10 * fleet) - expect(counts.registry - before.registry).toBe(10) - expect(counts.registryEntries - before.registryEntries).toBe(10 * (3 * fleet + 2 * repoCount)) - expect(counts.contexts).toBe(before.contexts) - expect(counts.contextEntries).toBe(before.contextEntries) - expect(counts.plan).toBe(before.plan) - expect(counts.visibility).toBe(before.visibility) - const snapshots = { ...counts } - function statusMap(value: 'connected' | 'disconnected') { - const map = new Map([['outside', ssh(value, 'outside')]]) - const get = map.get.bind(map) - map.get = (key) => { - if (key.startsWith('target-')) { - counts.statusReads++ - } - return get(key) - } - return map - } - for (let sequence = 0; sequence < 10; sequence++) { - act(() => - useAppStore.setState({ - sshConnectionStates: statusMap(sequence % 2 ? 'connected' : 'disconnected') - }) - ) - } - expect(renders).toBe(settledRenders) - expect(result.current).toBe(first) - expect(counts.gates - snapshots.gates).toBe(0) - expect(counts.contexts - snapshots.contexts).toBe(10) - expect(counts.contextEntries - snapshots.contextEntries).toBe( - 10 * (2 * fleet + 1 + 2 * repoCount) - ) - expect(counts.registry - snapshots.registry).toBe(10) - expect(counts.registryEntries - snapshots.registryEntries).toBe( - 10 * (3 * fleet + 2 * repoCount) - ) - expect(counts.devices).toBe(snapshots.devices) - expect(counts.plan).toBe(before.plan) - expect(counts.dependencies).toBe(before.dependencies) - expect(counts.dependencyEntries).toBe(before.dependencyEntries) - expect(counts.snapshots).toBe(before.snapshots) - expect(fetch).toHaveBeenCalledTimes(2 * repoCount) - expect(counts.statusReads - snapshots.statusReads).toBe(10 * dependencyCount) - const localSsh = { ...counts } - for (let sequence = 0; sequence < 10; sequence++) { - act(() => - useAppStore - .getState() - .setEnvironmentSshConnectionState( - 'B', - 'outside', - ssh(sequence % 2 ? 'connected' : 'disconnected', 'outside') - ) - ) - } - expect(renders).toBe(settledRenders) - expect(result.current).toBe(first) - expect(counts.gates - localSsh.gates).toBe(0) - expect(counts.registry).toBe(localSsh.registry) - expect(counts.devices).toBe(localSsh.devices) - expect(counts.contexts).toBe(localSsh.contexts) - expect(counts.dependencies).toBe(before.dependencies) - expect(counts.snapshots).toBe(before.snapshots) - const equal = { ...counts } - act(() => publish('host-0', 2, { status: { ...status('host-0'), liveTabCount: 99 } })) - expect(renders).toBe(settledRenders) - expect(result.current).toBe(first) - expect(counts.gates - equal.gates).toBe(0) - expect(counts.deviceEntries - equal.deviceEntries).toBe(fleet) - act(() => - useAppStore - .getState() - .setEnvironmentSshConnectionState('host-0', 'target-0', ssh('disconnected', 'target-0')) - ) - expect(renders).toBe(settledRenders) - expect(result.current).toBe(first) - act(() => - useAppStore.setState({ - sshConnectionStates: new Map([['target-0', ssh('connected', 'target-0')]]) - }) - ) - expect(fetch).toHaveBeenCalledTimes(4 * repoCount) - expect(counts.dependencies).toBe(before.dependencies) - expect(counts.snapshots - before.snapshots).toBe(2 * repoCount) - expect(renders).toBeGreaterThan(settledRenders) - const catalogBuilds = counts.dependencies - act(() => - useAppStore.setState((state) => ({ - repos: state.repos.map((repo, index) => - index === 0 ? { ...repo, path: '/moved/repo' } : repo - ) - })) - ) - expect(counts.dependencies - catalogBuilds).toBe(2 * repoCount) - expect(fetch).toHaveBeenCalledTimes(6 * repoCount) - // Requests remain accepted when their snapshot becomes null; route/force policy is unchanged. - act(() => useAppStore.setState({ projectGroups: [], folderWorkspaces: [] })) - expect(fetch).toHaveBeenCalledTimes(8 * repoCount) - expect( - fetch.mock.calls.every( - ([, options]) => options.force && options.runtimeEnvironmentId === 'host-0' - ) - ).toBe(true) - const healthPlans = counts.plan - const occurrencePlan = result.current.section.occurrencePlan - act(() => publish('host-0', 3, { verification: 'checking', transport: 'connecting' })) - expect( - result.current.section.hostOptions.find((host) => host.id === 'runtime:host-0')?.health - ).toBe('connecting') - expect(counts.plan).toBe(healthPlans) - expect(result.current.section.occurrencePlan).toBe(occurrencePlan) - act(() => publish('host-0', 4, { status: { ...status('host-0'), pairedDeviceId: 'other' } })) - expect(result.current.visible.visibleWorktrees).toHaveLength( - 3 * (repoCount - Math.ceil(repoCount / hostCount)) - ) - } -) - it('keeps real collapsed same-name header contexts reactive through parent host options and row scope', () => { const fixture = collapsedSameNameProjectHeaders() useAppStore.setState({ diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/rendered-sidebar-worktree-order.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/rendered-sidebar-worktree-order.test.ts index 0b4125d7556..ea4899ce2cc 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/rendered-sidebar-worktree-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/rendered-sidebar-worktree-order.test.ts @@ -7,11 +7,9 @@ import { setVisibleWorktreeShortcutTargets } from './visible-worktrees' import type { AppState } from '@/store/types' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' + import type { Repo } from '../../../../../../shared/repo-types' import type { Worktree } from '../../../../../../shared/worktree/types' -import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' const initialState = useAppStore.getInitialState() @@ -38,10 +36,6 @@ function makeWorktree(id: string, overrides: Partial = {}): Worktree { } as Worktree } -function makeMainWorktree(id: string, overrides: Partial = {}): Worktree { - return makeWorktree(id, { isMainWorktree: true, branch: 'refs/heads/main', ...overrides }) -} - function makeRepo(id = 'repo1', overrides: Partial = {}): Repo { return { id, @@ -53,42 +47,6 @@ function makeRepo(id = 'repo1', overrides: Partial = {}): Repo { } as unknown as Repo } -function makeProjectGroup(id: string, overrides: Partial = {}): ProjectGroup { - return { - id, - name: id, - parentPath: `/tmp/${id}`, - connectionId: null, - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1, - ...overrides - } as ProjectGroup -} - -function makeFolderWorkspace(id: string, projectGroupId: string): FolderWorkspace { - return { - id, - projectGroupId, - name: id, - folderPath: `/tmp/${id}`, - connectionId: null, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } -} - /** Clear the published order so the closed-sidebar fallback must recompute it from store fields. */ function seedStore(worktrees: Worktree[], overrides: Partial = {}): void { useAppStore.setState( @@ -116,170 +74,6 @@ describe('closed-sidebar Cmd+1-9 ordering (#9497)', () => { useAppStore.setState(initialState, true) }) - it('hoists the repo main worktree first for automatic sorting, matching the rendered sidebar', () => { - // Arrange flat sort to disagree with grouping so the main-worktree hoist is observable. - const feature = makeWorktree('wt-feature', { lastActivityAt: 1 }) - const main = makeMainWorktree('wt-main', { lastActivityAt: 0 }) - seedStore([feature, main], { sortBy: 'recent' }) - - const order = getVisibleWorktreeIds() - - expect(order).toEqual(['wt-main', 'wt-feature']) - expect(order).not.toEqual(['wt-feature', 'wt-main']) - }) - - it('ignores the agent-send collapse override, which only applies to a mounted list', () => { - // The send-collapse override requires a mounted list, which publishes its own order and bypasses this fallback. - const main = makeMainWorktree('wt-main') - const feature = makeWorktree('wt-feature') - seedStore([main, feature], { - collapsedGroups: new Set(['project:repo:repo1']), - agentSendPopoverTargetMode: { - worktreeId: 'wt-feature', - id: 'send-1', - instanceId: 'inst-1' - } as AppState['agentSendPopoverTargetMode'] - }) - - expect(getVisibleWorktreeIds()).toEqual([]) - - setVisibleWorktreeIds(['wt-feature', 'wt-main']) - expect(getVisibleWorktreeIds()).toEqual(['wt-feature', 'wt-main']) - }) - - it('places a pinned workspace per the pinned section under both display policies', () => { - const main = makeMainWorktree('wt-main') - const plain = makeWorktree('wt-plain') - const pinned = makeWorktree('wt-pinned', { isPinned: true }) - - for (const showPinnedWorktreesInGroups of [false, true]) { - seedStore([main, plain, pinned], { - settings: { showPinnedWorktreesInGroups } as AppState['settings'] - }) - - const order = getVisibleWorktreeIds() - - expect(order).toEqual([...new Set(order)]) - expect([...order].sort()).toEqual(['wt-main', 'wt-pinned', 'wt-plain']) - expect(order[0]).toBe(showPinnedWorktreesInGroups ? 'wt-main' : 'wt-pinned') - } - }) - - it('elides members of a collapsed group, which render no card to number', () => { - const main = makeMainWorktree('wt-main') - const feature = makeWorktree('wt-feature') - seedStore([main, feature], { collapsedGroups: new Set(['project:repo:repo1']) }) - - expect(getVisibleWorktreeIds()).toEqual([]) - }) - - it('numbers folder workspaces, which the flat fallback omitted entirely', () => { - const group = makeProjectGroup('group-1') - const folderWorkspace = makeFolderWorkspace('fw-1', group.id) - seedStore([makeMainWorktree('wt-main')], { - projectGroups: [group], - folderWorkspaces: [folderWorkspace] - }) - - expect(getVisibleWorktreeIds()).toContain(folderWorkspaceKey(folderWorkspace.id)) - }) - - it('numbers a workspace created while nothing was published', () => { - // A retained cache cannot discover newly created IDs; this path must recompute membership. - seedStore([makeMainWorktree('wt-main')]) - expect(getVisibleWorktreeIds()).toEqual(['wt-main']) - - seedStore([makeMainWorktree('wt-main'), makeWorktree('wt-created-while-closed')]) - - expect(getVisibleWorktreeIds()).toEqual(['wt-created-while-closed', 'wt-main']) - }) - - it('treats a published order as authoritative, including an explicitly empty one', () => { - seedStore([makeMainWorktree('wt-main'), makeWorktree('wt-feature')]) - - setVisibleWorktreeIds(['x']) - expect(getVisibleWorktreeIds()).toEqual(['x']) - - setVisibleWorktreeIds([]) - expect(getVisibleWorktreeIds()).toEqual([]) - - setVisibleWorktreeIds(null) - expect(getVisibleWorktreeIds()).toEqual(['wt-feature', 'wt-main']) - }) - - it('honors an explicit host filter and keeps the all-hosts default unfiltered', () => { - const localRepo = makeRepo('repo1') - const sshRepo = makeRepo('repo-ssh', { connectionId: 'my-target' }) - // Base sort interleaves the hosts, so any host grouping has to reorder them. - const localMain = makeMainWorktree('wt-local-main', { sortOrder: 3 }) - const remoteMain = makeMainWorktree('wt-remote-main', { repoId: 'repo-ssh', sortOrder: 2 }) - const localFeature = makeWorktree('wt-local-feature', { sortOrder: 1 }) - const remoteFeature = makeWorktree('wt-remote-feature', { repoId: 'repo-ssh', sortOrder: 0 }) - const storeOverrides = { - repos: [localRepo, sshRepo], - worktreesByRepo: { - repo1: [localMain, localFeature], - 'repo-ssh': [remoteMain, remoteFeature] - }, - sshTargetLabels: new Map([['my-target', 'My Target']]) - } as Partial - - seedStore([], storeOverrides) - expect(getVisibleWorktreeIds()).toEqual([ - 'wt-local-main', - 'wt-local-feature', - 'wt-remote-main', - 'wt-remote-feature' - ]) - - seedStore([], { ...storeOverrides, visibleWorkspaceHostIds: ['local'] }) - expect(getVisibleWorktreeIds()).toEqual(['wt-local-main', 'wt-local-feature']) - - seedStore([], { - ...storeOverrides, - visibleWorkspaceHostIds: ['ssh:my-target', 'local'] - }) - // Host registry order, rather than filter argument order, keeps each host’s workspaces contiguous. - expect(getVisibleWorktreeIds()).toEqual([ - 'wt-local-main', - 'wt-local-feature', - 'wt-remote-main', - 'wt-remote-feature' - ]) - }) - - it("orders a card in a paired host's own column by that column, not the default", () => { - const today = makeWorktree('wt-today', { - hostId: 'runtime:env-1', - workspaceStatus: 'status-6', - sortOrder: 0 - }) - const todo = makeWorktree('wt-todo', { workspaceStatus: 'todo', sortOrder: 1 }) - const doing = makeWorktree('wt-doing', { workspaceStatus: 'in-progress', sortOrder: 2 }) - seedStore([today, todo, doing], { - groupBy: 'workspace-status', - workspaceStatuses: [ - { id: 'in-progress', label: 'In progress' }, - { id: 'todo', label: 'Todo' } - ], - hostWorkspaceStatuses: { 'env-1': [{ id: 'status-6', label: '🔥 今天收尾' }] } - }) - - expect(getVisibleWorktreeIds()).toEqual(['wt-doing', 'wt-todo', 'wt-today']) - }) - - it('keeps the sort layer intact for smart and comparator sort modes', () => { - const main = makeMainWorktree('wt-main') - const older = makeWorktree('wt-older', { displayName: 'b-older', lastActivityAt: 1 }) - const newer = makeWorktree('wt-newer', { displayName: 'a-newer', lastActivityAt: 999 }) - - seedStore([older, newer, main], { sortBy: 'smart' }) - expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-newer', 'wt-older']) - - seedStore([older, newer, main], { sortBy: 'name' }) - expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-newer', 'wt-older']) - }) - it('keeps same-id hosts as separate closed-sidebar shortcut positions', () => { seedStore([ makeWorktree('repo1::/same', { hostId: 'local' }), diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx index a1dccadd12a..1ef079b9034 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.test.tsx @@ -106,51 +106,6 @@ describe('useSidebarWorktreeSortOrder', () => { useAppStore.setState(initialState, true) }) - it('renders once per sortEpoch bump in Manual (no hook-initiated re-render)', () => { - seed( - [makeWorktree('a', 'A', { manualOrder: 2 }), makeWorktree('b', 'B', { manualOrder: 1 })], - 'manual' - ) - const { renders } = renderSortOrder() - const before = renders.count - for (let i = 0; i < 10; i++) { - act(() => bumpSortEpoch()) - } - expect(renders.count - before).toBe(10) - }) - - it('applies a Manual reorder in the same commit as the bump', () => { - seed( - [makeWorktree('a', 'A', { manualOrder: 2 }), makeWorktree('b', 'B', { manualOrder: 1 })], - 'manual' - ) - const { result } = renderSortOrder({ strict: true }) - expect(result.current).toEqual(keys('a', 'b')) - act(() => - replaceWorktrees([ - makeWorktree('a', 'A', { manualOrder: 2 }), - makeWorktree('b', 'B', { manualOrder: 3 }) - ]) - ) - expect(result.current).toEqual(keys('b', 'a')) - }) - - it('survives a burst of synchronous store bumps in Manual', () => { - seed( - [makeWorktree('a', 'A', { manualOrder: 2 }), makeWorktree('b', 'B', { manualOrder: 1 })], - 'manual' - ) - const view = renderSortOrderOutsideAct('manual') - try { - for (let i = 0; i < 80; i++) { - flushSync(() => bumpSortEpoch()) - } - expect(view.errors).toEqual([]) - } finally { - view.unmount() - } - }) - it('survives 80 synchronous bumps in Recent while worktrees are added, then settles in order', () => { vi.useFakeTimers() let worktrees = [makeWorktree('w0', 'W0', { lastActivityAt: 0 })] @@ -185,190 +140,6 @@ describe('useSidebarWorktreeSortOrder', () => { } }) - it('debounces Recent re-sorts until the settle window passes', () => { - vi.useFakeTimers() - seed( - [ - makeWorktree('a', 'A', { lastActivityAt: 2_000 }), - makeWorktree('b', 'B', { lastActivityAt: 1_000 }) - ], - 'recent' - ) - const { result } = renderSortOrder() - expect(result.current).toEqual(keys('a', 'b')) - act(() => - replaceWorktrees([ - makeWorktree('a', 'A', { lastActivityAt: 2_000 }), - makeWorktree('b', 'B', { lastActivityAt: 3_000 }) - ]) - ) - expect(result.current).toEqual(keys('a', 'b')) - act(() => vi.advanceTimersByTime(3_000)) - expect(result.current).toEqual(keys('b', 'a')) - }) - - it('applies an added worktree immediately in debounced modes', () => { - vi.useFakeTimers() - seed([makeWorktree('a', 'A')], 'name') - const { result } = renderSortOrder() - act(() => replaceWorktrees([makeWorktree('a', 'A'), makeWorktree('0', '0 first')])) - expect(result.current).toEqual(keys('0', 'a')) - }) - - it('re-sorts on Manual -> Recent and stays put at settle', () => { - vi.useFakeTimers() - seed( - [ - makeWorktree('a', 'A', { manualOrder: 2, lastActivityAt: 1_000 }), - makeWorktree('b', 'B', { manualOrder: 1, lastActivityAt: 2_000 }) - ], - 'manual' - ) - const { result } = renderSortOrder() - for (let i = 0; i < 5; i++) { - act(() => bumpSortEpoch()) - } - expect(result.current).toEqual(keys('a', 'b')) - act(() => useAppStore.getState().setSortBy('recent')) - expect(result.current).toEqual(keys('b', 'a')) - const afterSwitch = result.current - act(() => vi.advanceTimersByTime(3_000)) - expect(result.current).toBe(afterSwitch) - }) - - it('restarts the settle window on every bump in a burst', () => { - vi.useFakeTimers() - seed( - [ - makeWorktree('a', 'A', { lastActivityAt: 2_000 }), - makeWorktree('b', 'B', { lastActivityAt: 1_000 }) - ], - 'recent' - ) - const { result } = renderSortOrder() - act(() => - replaceWorktrees([ - makeWorktree('a', 'A', { lastActivityAt: 2_000 }), - makeWorktree('b', 'B', { lastActivityAt: 3_000 }) - ]) - ) - act(() => vi.advanceTimersByTime(2_000)) - act(() => bumpSortEpoch()) - act(() => vi.advanceTimersByTime(2_000)) - expect(result.current).toEqual(keys('a', 'b')) - act(() => vi.advanceTimersByTime(1_000)) - expect(result.current).toEqual(keys('b', 'a')) - }) - - it('applies a removed worktree immediately in debounced modes', () => { - vi.useFakeTimers() - seed( - [ - makeWorktree('a', 'A', { lastActivityAt: 3_000 }), - makeWorktree('b', 'B', { lastActivityAt: 2_000 }), - makeWorktree('c', 'C', { lastActivityAt: 1_000 }) - ], - 'recent' - ) - const { result } = renderSortOrder() - act(() => - replaceWorktrees([ - makeWorktree('b', 'B', { lastActivityAt: 2_000 }), - makeWorktree('c', 'C', { lastActivityAt: 4_000 }) - ]) - ) - expect(result.current).toEqual(keys('c', 'b')) - }) - - it('does not re-sort when rows change without a sortEpoch bump', () => { - vi.useFakeTimers() - seed( - [ - makeWorktree('a', 'A', { lastActivityAt: 2_000 }), - makeWorktree('b', 'B', { lastActivityAt: 1_000 }) - ], - 'recent' - ) - const { result } = renderSortOrder() - const before = result.current - // Same shape as a stale-host purge that drops/changes rows without bumping sortEpoch. - act(() => - useAppStore.setState({ - worktreesByRepo: { - [repo.id]: [ - makeWorktree('a', 'A', { lastActivityAt: 2_000 }), - makeWorktree('b', 'B', { lastActivityAt: 3_000 }), - makeWorktree('c', 'C', { lastActivityAt: 4_000 }) - ] - } - }) - ) - act(() => vi.advanceTimersByTime(3_000)) - expect(result.current).toBe(before) - expect(vi.getTimerCount()).toBe(0) - }) - - it('switching sort mode cancels a pending settle and re-sorts right away', () => { - vi.useFakeTimers() - seed( - [ - makeWorktree('a', 'Zeta', { lastActivityAt: 2_000 }), - makeWorktree('b', 'Alpha', { lastActivityAt: 1_000 }) - ], - 'recent' - ) - const { result } = renderSortOrder() - act(() => - replaceWorktrees([ - makeWorktree('a', 'Zeta', { lastActivityAt: 2_000 }), - makeWorktree('b', 'Alpha', { lastActivityAt: 3_000 }) - ]) - ) - expect(vi.getTimerCount()).toBe(1) - act(() => useAppStore.getState().setSortBy('name')) - expect(result.current).toEqual(keys('b', 'a')) - expect(useAppStore.getState().settledSortEpoch).toBe(useAppStore.getState().sortEpoch) - expect(vi.getTimerCount()).toBe(0) - }) - - it.each(['smart', 'manual'] as const)( - 'follows another client order save while %s ranks by persisted sortOrder', - (sortBy) => { - seed( - [makeWorktree('a', 'A', { sortOrder: 2 }), makeWorktree('b', 'B', { sortOrder: 1 })], - sortBy - ) - const { result } = renderSortOrder() - expect(result.current).toEqual(keys('a', 'b')) - act(() => publishSortOrders({ a: 1, b: 2 })) - expect(result.current).toEqual(keys('b', 'a')) - expect(useAppStore.getState().sortEpoch).toBe(1) - } - ) - - it('keeps live Smart order steady when only persisted sortOrder changes', () => { - const persistSortOrder = vi.fn(async () => ({ updated: 0 })) - vi.stubGlobal('api', { worktrees: { persistSortOrder } }) - try { - seed( - [makeWorktree('a', 'A', { sortOrder: 2 }), makeWorktree('b', 'B', { sortOrder: 1 })], - 'smart' - ) - useAppStore.setState({ - tabsByWorktree: { a: [makeTerminalTab({ id: 'tab-a', worktreeId: 'a' })] }, - ptyIdsByTabId: { 'tab-a': ['pty-a'] } - }) - const { result } = renderSortOrder() - const before = result.current - const savesBefore = persistSortOrder.mock.calls.length - act(() => publishSortOrders({ a: 1, b: 2 })) - expect(result.current).toBe(before) - expect(persistSortOrder.mock.calls.length).toBe(savesBefore) - } finally { - vi.unstubAllGlobals() - } - }) - it('keeps Smart latched after its live signal ends, until the list unmounts', () => { vi.stubGlobal('api', { worktrees: { persistSortOrder: vi.fn(async () => ({ updated: 0 })) } }) try {