mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove low-value sidebar tests (#38525)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
-334
@@ -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<string, number>(),
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
-258
@@ -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> = {}): 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<string>
|
||||
}): Row[] {
|
||||
const worktrees = options.worktrees ?? [worktree]
|
||||
return buildRows(
|
||||
options.groupBy,
|
||||
worktrees,
|
||||
new Map([[GROUPED_REPO.id, GROUPED_REPO]]),
|
||||
null,
|
||||
options.collapsedGroups ?? new Set<string>(),
|
||||
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<Row, { type: 'folder-workspace' }>[] {
|
||||
return rows.filter(
|
||||
(row): row is Extract<Row, { type: 'folder-workspace' }> => 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([])
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
-238
@@ -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<string>): 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]]))
|
||||
})
|
||||
})
|
||||
@@ -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<Parameters<typeof addHostSectionRows>[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<Row, { type: 'header' }> {
|
||||
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<Row, { type: 'header' }> {
|
||||
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<Row, { type: 'header' }> {
|
||||
return {
|
||||
...header(`repo:${project.id}`, project.displayName),
|
||||
repo: project
|
||||
}
|
||||
}
|
||||
|
||||
function item(id: string, project: Repo): Extract<Row, { type: 'item' }> {
|
||||
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<Row, { type: 'item' }> {
|
||||
const row = item(id, project)
|
||||
row.worktree.isPinned = true
|
||||
row.rowKey = `${sectionKey}:${id}`
|
||||
row.sectionKey = sectionKey
|
||||
return row
|
||||
}
|
||||
|
||||
function folderWorkspaceRow(
|
||||
connectionId: string | null
|
||||
): Extract<Row, { type: 'folder-workspace' }> {
|
||||
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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
-527
@@ -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<string>()],
|
||||
['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([])
|
||||
})
|
||||
})
|
||||
+3
-543
@@ -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<string, Worktree>([
|
||||
[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<string, Worktree>([
|
||||
[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')
|
||||
})
|
||||
})
|
||||
|
||||
-418
@@ -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'])
|
||||
})
|
||||
})
|
||||
-462
@@ -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')
|
||||
})
|
||||
})
|
||||
+4
-682
@@ -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 = {
|
||||
|
||||
+1
-477
@@ -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]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
-327
@@ -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<string, unknown>,
|
||||
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<number>
|
||||
scrollAnchorRef: React.RefObject<unknown>
|
||||
}>
|
||||
|
||||
let WorktreeList: WorktreeListComponent
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector(mockStore.state)) as ((
|
||||
selector: (state: Record<string, unknown>) => unknown
|
||||
) => unknown) & {
|
||||
getState: () => Record<string, unknown>
|
||||
}
|
||||
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<typeof ProjectHeaderDragContract>()),
|
||||
isRepoHeaderActionTarget: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/hover-card', () => ({
|
||||
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
HoverCardContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
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 }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
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: () => <div>Agent row</div>,
|
||||
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 <div data-mock-worktree-card={worktree.id} />
|
||||
})
|
||||
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<string, unknown> {
|
||||
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<string>(),
|
||||
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<void> {
|
||||
await act(async () => {
|
||||
// Fresh ref props bypass the list’s own memo to isolate whether individual cards bail out.
|
||||
root.render(
|
||||
<WorktreeList scrollOffsetRef={{ current: 0 }} scrollAnchorRef={{ current: null }} />
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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<string, Worktree[]>)['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')
|
||||
})
|
||||
})
|
||||
-175
@@ -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> = {}
|
||||
): 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<string>(),
|
||||
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(/<div[^>]*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"'
|
||||
)
|
||||
})
|
||||
})
|
||||
-33
@@ -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<string>).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()
|
||||
|
||||
-627
@@ -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<string, unknown>,
|
||||
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<number>
|
||||
scrollAnchorRef: React.RefObject<unknown>
|
||||
}>
|
||||
|
||||
let WorktreeList: WorktreeListComponent
|
||||
|
||||
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
|
||||
return {
|
||||
fetchFolderWorkspacePathStatus: vi.fn(),
|
||||
folderWorkspaces: [],
|
||||
folderWorkspacePathStatuses: {},
|
||||
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
|
||||
getFreshFolderWorkspacePathStatus: () => null
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => 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<typeof ProjectHeaderDragContract>()),
|
||||
isRepoHeaderActionTarget: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/hover-card', () => ({
|
||||
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
HoverCardContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-hover-card-content="">{children}</div>
|
||||
),
|
||||
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 }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
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 }) => (
|
||||
<div data-agent-worktree-id={worktreeId}>Agent row</div>
|
||||
),
|
||||
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
|
||||
}))
|
||||
|
||||
vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => (
|
||||
<div data-worktree-context-menu-scope>{children}</div>
|
||||
),
|
||||
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>
|
||||
}): 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> = {}): 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<string, unknown> {
|
||||
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<string, WorktreeLineage> = {
|
||||
[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<string>(),
|
||||
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<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorktreeList scrollOffsetRef={{ current: 0 }} scrollAnchorRef={{ current: null }} />
|
||||
)
|
||||
})
|
||||
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<HTMLElement>('[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<HTMLElement>('[data-worktree-lineage-children]')
|
||||
]
|
||||
const childRow = container.querySelector<HTMLElement>(
|
||||
'[id="worktree-list-option-all%3A%7Cchild"]'
|
||||
)
|
||||
const grandchildRow = container.querySelector<HTMLElement>(
|
||||
'[id="worktree-list-option-all%3A%7Cgrandchild"]'
|
||||
)
|
||||
const childSurface = childRow?.querySelector<HTMLElement>('[data-worktree-card-surface="true"]')
|
||||
const grandchildSurface = grandchildRow?.querySelector<HTMLElement>(
|
||||
'[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<HTMLElement>(
|
||||
'[role="option"][data-worktree-id="parent"]'
|
||||
)!
|
||||
const child = container.querySelector<HTMLElement>('[role="option"][data-worktree-id="child"]')!
|
||||
const boundaries = container.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>(
|
||||
'[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<HTMLElement>(
|
||||
`[role="option"][data-worktree-id="${worktreeId}"]`
|
||||
)
|
||||
const target =
|
||||
pressed === 'option'
|
||||
? option
|
||||
: option?.querySelector<HTMLElement>('[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<HTMLElement>(
|
||||
'[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()
|
||||
})
|
||||
})
|
||||
-460
@@ -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<string, unknown>,
|
||||
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<number>
|
||||
scrollAnchorRef: React.RefObject<unknown>
|
||||
}>
|
||||
|
||||
let WorktreeList: WorktreeListComponent
|
||||
|
||||
function makeFolderWorkspacePathStatusMockState(): Record<string, unknown> {
|
||||
return {
|
||||
fetchFolderWorkspacePathStatus: vi.fn(),
|
||||
folderWorkspaces: [],
|
||||
folderWorkspacePathStatuses: {},
|
||||
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
|
||||
getFreshFolderWorkspacePathStatus: () => null
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => 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<typeof ProjectHeaderDragContract>()),
|
||||
isRepoHeaderActionTarget: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/hover-card', () => ({
|
||||
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
HoverCardContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-hover-card-content="">{children}</div>
|
||||
),
|
||||
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 }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
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 }) => (
|
||||
<div data-agent-worktree-id={worktreeId}>Agent row</div>
|
||||
),
|
||||
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<string, unknown> {
|
||||
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<string>(),
|
||||
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<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorktreeList scrollOffsetRef={{ current: 0 }} scrollAnchorRef={{ current: null }} />
|
||||
)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
function makeWorktreeIdDataTransfer(worktreeIds: readonly string[]): DataTransfer {
|
||||
const [firstId] = worktreeIds
|
||||
const data: Record<string, string> = {
|
||||
[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<HTMLElement>(
|
||||
`[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<void> {
|
||||
const event = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
value: dataTransfer
|
||||
})
|
||||
await act(async () => {
|
||||
header.dispatchEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
function committedStatusUpdates(): Map<string, Partial<WorktreeMeta>> {
|
||||
expect(mockStore.updateWorktreesMeta).toHaveBeenCalledTimes(1)
|
||||
const input = mockStore.updateWorktreesMeta.mock.calls[0]![0] as
|
||||
| readonly WorktreeMetaBatchUpdate[]
|
||||
| ReadonlyMap<string, Partial<WorktreeMeta>>
|
||||
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)
|
||||
})
|
||||
})
|
||||
-238
@@ -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<string>,
|
||||
lineageById: Record<string, WorktreeLineage> = {}
|
||||
) {
|
||||
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)
|
||||
})
|
||||
+2
-349
@@ -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<never>(() => {}))
|
||||
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({
|
||||
|
||||
+1
-207
@@ -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> = {}): Worktree {
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
function makeMainWorktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
return makeWorktree(id, { isMainWorktree: true, branch: 'refs/heads/main', ...overrides })
|
||||
}
|
||||
|
||||
function makeRepo(id = 'repo1', overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id,
|
||||
@@ -53,42 +47,6 @@ function makeRepo(id = 'repo1', overrides: Partial<Repo> = {}): Repo {
|
||||
} as unknown as Repo
|
||||
}
|
||||
|
||||
function makeProjectGroup(id: string, overrides: Partial<ProjectGroup> = {}): 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<AppState> = {}): 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<AppState>
|
||||
|
||||
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' }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user