diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 60be225885b..cc1e14e5bcc 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -829,7 +829,6 @@ 13 test-no-wall-clock:src/main/startup/windows-install-dir-acl-recovery.test.ts 3 test-no-wall-clock:src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx 3 test-no-wall-clock:src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx -13 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx 14 test-no-real-time-wait:src/main/ipc/worktrees/creation/worktree-base-directory-poller.test.ts 14 test-no-wall-clock:src/wsl-guest/agent-hook-integration.test.ts 15 test-no-wall-clock:src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts @@ -1264,7 +1263,6 @@ 4 test-no-wall-clock:src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts 4 test-no-wall-clock:src/main/ssh/ssh-remote-commands.test.ts 4 test-no-wall-clock:src/main/updater/lifecycle/serve-supervisor-install-park.test.ts -4 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx 4 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts 4 test-no-wall-clock:tests/e2e/cross-version-wire/release-checkout.unit.test.ts 4 test-timeout-needs-reason:src/main/claude/claude-agent-sdk-exit-proof.test.ts @@ -1308,7 +1306,6 @@ 5 test-timeout-needs-reason:src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts 5 test-timeout-needs-reason:src/main/pty/process-retirement/node-pty-master-fd-retirement.test.ts 5 test-timeout-needs-reason:src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts -5 test-timeout-needs-reason:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx 5 test-timeout-needs-reason:tests/e2e/browser-agent-input-focus-isolation.spec.ts 5 test-timeout-needs-reason:tests/e2e/persisted-session-production-upgrade.spec.ts 5 test-timeout-needs-reason:tests/e2e/ssh-orcad-auto-convert.spec.ts @@ -1351,7 +1348,6 @@ 7 test-no-wall-clock:src/renderer/src/store/slices/jira.test.ts 7 test-no-wall-clock:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts 7 test-timeout-needs-reason:mobile/src/tasks/worktree-create-retry.test.ts -7 test-timeout-needs-reason:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx 8 test-no-in-test-import-actual:src/main/daemon/endpoint/publication/testing/daemon-endpoint-publish.test.ts 8 test-no-real-time-wait:tests/e2e/terminal-column-desync-repro.spec.ts 8 test-no-wall-clock:src/main/ai-vault-search/session-search-indexer.test.ts diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 8fd6a9e81a1..435f89609a3 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -7826,13 +7826,7 @@ "src/renderer/src/components/sidebar/sidebar-feedback/SidebarFeedbackDialog.test.tsx": 2529, "src/renderer/src/components/sidebar/StalledTerminalDaemonBanner.test.tsx": 320, "src/renderer/src/components/sidebar/workspace-board/viewport/WorkspaceKanbanLaneGrid.test.tsx": 360, - "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.affiliate-list-mode.test.tsx": 2513, - "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx": 3316, - "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx": 2817, - "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.quick-actions.test.tsx": 3258, "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.single-reference-details.test.tsx": 2032, - "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.ssh-reconnect-prompt.test.tsx": 2674, - "src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.test.tsx": 2593, "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, @@ -7857,7 +7851,6 @@ "src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-parallel-flow.test.ts": 938, "src/renderer/src/components/sidebar/workspace-card/review/folder-workspace-card-pr-display.test.ts": 377, "src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-expiry.test.tsx": 2558, - "src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.test.tsx": 2113, "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, @@ -7871,11 +7864,9 @@ "src/renderer/src/components/sidebar/worktree-list/grouping/pinned-section-worktrees.test.ts": 446, "src/renderer/src/components/sidebar/workspace-actions/deletion/preserved-branch-batch-toast.test.tsx": 627, "src/renderer/src/components/sidebar/worktree-list/drag/project-group-header-drop.test.ts": 198, - "src/renderer/src/components/sidebar/worktree-list/drag/project-header-drop.test.ts": 206, "src/renderer/src/components/sidebar/project-move-pause-follower.test.ts": 938, "src/renderer/src/components/sidebar/host-path-browser/remote-file-browser-entry-actions.test.ts": 289, "src/renderer/src/components/sidebar/worktree-list/listing/rendered-sidebar-worktree-order.test.ts": 1873, - "src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.test.ts": 1810, "src/renderer/src/components/sidebar/worktree-list/parity/sidebar-list-parity.test.tsx": 7474, "src/renderer/src/components/sidebar/sidebar-live-width.test.ts": 177, "src/renderer/src/components/sidebar/project-admission/drop/sidebar-project-drop.test.ts": 227, @@ -7921,8 +7912,6 @@ "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/drag/status-drop-twin-host.test.ts": 2078, - "src/renderer/src/components/sidebar/worktree-list/drag/twin-reorder.test.ts": 1831, "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, @@ -7955,8 +7944,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-drag-autoscroll.test.ts": 60, - "src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-geometry.test.ts": 86, "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, diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.affiliate-list-mode.test.tsx deleted file mode 100644 index c5f50bb347c..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.affiliate-list-mode.test.tsx +++ /dev/null @@ -1,324 +0,0 @@ -// @vitest-environment happy-dom - -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' - -const openModal = vi.fn() -const setRenamingWorktreeId = vi.fn() -const updateWorktreeMeta = vi.fn() -const testDoubles = vi.hoisted(() => ({ - activateWorktreeFromSidebar: vi.fn() -})) -const sleepMocks = vi.hoisted(() => ({ - sleeping: false -})) -let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'comment'] -let settings: Partial | null = null - -vi.mock('@/store', () => { - const getState = () => ({ - browserTabsByWorktree: {}, - createBrowserTab: vi.fn(), - deleteFolderWorkspace: vi.fn(), - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch: vi.fn(), - fetchIssue: vi.fn(), - fetchLinearIssue: vi.fn(), - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - openTaskPage: vi.fn(), - projectGroups: [], - ptyIdsByTabId: {}, - remoteBranchConflictByWorktreeId: {}, - renamingWorktreeId: null, - setActiveWorktree: vi.fn(), - setRemoteBrowserPageHandle: vi.fn(), - setRenamingWorktreeId, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - unifiedTabsByWorktree: {}, - updateWorktreeMeta, - workspacePortScan: null, - worktreeCardProperties - }) - return { - useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { - getState, - subscribe: () => () => {} - }) - } -}) - -vi.mock('@/components/ui/hover-card', () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - activateWorktreeFromSidebar: testDoubles.activateWorktreeFromSidebar -})) - -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'idle' -})) - -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => sleepMocks.sleeping -})) - -vi.mock('../activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: () => null -})) - -vi.mock('../activity/WorktreeCardAgents', () => ({ - default: () =>
-})) - -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' -})) - -vi.mock('../identity/WorktreeTitleInlineRename', () => ({ - WorktreeTitleInlineRename: ({ - disabled, - displayName - }: { - disabled?: boolean - displayName: string - }) => ( - - {displayName} - - ) -})) - -import WorktreeCard from './WorktreeCard' - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/repo', - displayName: 'orca', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo/worktrees/affiliate', - repoId: 'repo-1', - path: '/repo/worktrees/affiliate', - displayName: 'Affiliate child', - branch: 'refs/heads/affiliate-child', - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: 'read only', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - ...overrides - } -} - -describe('WorktreeCard affiliate list mode', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - vi.clearAllMocks() - worktreeCardProperties = ['status', 'comment'] - settings = null - sleepMocks.sleeping = false - }) - - afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() - }) - - it('keeps the card visual surface but disables mutating list interactions', () => { - act(() => { - root.render( - - ) - }) - - const surface = container.querySelector('[data-worktree-card-surface="true"]') - const parentContent = container.querySelector( - '[data-worktree-card-parent-content=""]' - ) - expect(surface).not.toBeNull() - expect(parentContent?.className).toContain('gap-0.5') - expect(parentContent?.className).not.toMatch(/\bpl-/) - const statusSlot = container.querySelector('[data-worktree-card-status-slot]') - expect(statusSlot?.className).toContain('px-1') - expect(container.querySelector('[data-testid="context-menu-wrapper"]')).toBeNull() - expect(surface?.getAttribute('draggable')).toBe('false') - expect( - container.querySelector('[data-testid="inline-rename"]')?.getAttribute('data-disabled') - ).toBe('true') - - act(() => { - surface?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) - }) - - expect(openModal).not.toHaveBeenCalled() - expect(setRenamingWorktreeId).not.toHaveBeenCalled() - expect(updateWorktreeMeta).not.toHaveBeenCalled() - - act(() => { - surface?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(testDoubles.activateWorktreeFromSidebar).toHaveBeenCalledWith( - 'repo-1::/repo/worktrees/affiliate', - 'local' - ) - }) - - it('still shows inline agent details in affiliate list mode', () => { - worktreeCardProperties = ['status', 'inline-agents'] - - act(() => { - root.render( - - ) - }) - - expect(container.querySelector('[data-testid="inline-agents"]')).not.toBeNull() - }) - - it('dims the full card surface for sleeping workspaces in new card style', () => { - sleepMocks.sleeping = true - settings = { experimentalNewWorktreeCardStyle: true } - - act(() => { - root.render( - - ) - }) - - expect(container.querySelector('[data-worktree-sleeping-dim=""]')).not.toBeNull() - const dim = container.querySelector('[data-worktree-sleeping-dim=""]') - expect(dim).not.toBeNull() - // Theme-token mixing must keep sleeping dim visible on light surfaces/custom tints without fading the whole backdrop. - expect(dim?.getAttribute('class') ?? '').not.toContain('opacity-') - expect(dim?.getAttribute('class') ?? '').not.toContain('backdrop-') - }) - - it('keeps awake cards at full opacity in new card style', () => { - settings = { experimentalNewWorktreeCardStyle: true } - - act(() => { - root.render( - - ) - }) - - expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull() - }) - - it('keeps legacy sleeping cards undimmed', () => { - sleepMocks.sleeping = true - - act(() => { - root.render( - - ) - }) - - expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull() - }) - - it('keeps the unread badge rendered on a dimmed sleeping card', () => { - sleepMocks.sleeping = true - settings = { experimentalNewWorktreeCardStyle: true } - - act(() => { - root.render( - - ) - }) - - expect(container.querySelector('[data-worktree-sleeping-dim=""]')).not.toBeNull() - expect(container.querySelector('[data-worktree-unread-alert=""]')).not.toBeNull() - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx deleted file mode 100644 index 355a1bcc599..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx +++ /dev/null @@ -1,842 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import React, { type ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' -import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { WorkspacePortScanResult } from '../../../../../../shared/workspace-ports' - -const fetchHostedReviewForBranch = vi.fn() -const fetchIssue = vi.fn() -const fetchLinearIssue = vi.fn() -const openModal = vi.fn() -const openTaskPage = vi.fn() -const updateWorktreeMeta = vi.fn() -const recordFeatureInteraction = vi.fn() -const replaceWorkspacePortScans = vi.fn() -const setWorkspacePortScanRefreshing = vi.fn() -const cacheTimerMocks = vi.hoisted(() => ({ - usePromptCacheCountdownStartedAt: vi.fn() -})) - -let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports'] -let hostedReviewCache: Record = {} -let issueCache: Record = {} -let projectGroups: unknown[] = [] -let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null -let settings: Partial | null = { compactWorktreeCards: true } -let agentActivityDisplayMode: 'compact' | 'full' | undefined -let mockInlineAgentRows: DashboardAgentRowData[] = [] - -vi.mock('@/store', () => { - const getState = () => ({ - browserTabsByWorktree: {}, - agentActivityDisplayMode, - createBrowserTab: vi.fn(), - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache, - issueCache, - linearIssueCache: {}, - openModal, - openTaskPage, - projectGroups, - ptyIdsByTabId: {}, - recordFeatureInteraction, - remoteBranchConflictByWorktreeId: {}, - setRemoteBrowserPageHandle: vi.fn(), - replaceWorkspacePortScans, - setWorkspacePortScanRefreshing, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - updateWorktreeMeta, - workspacePortScan, - worktreeCardProperties - }) - return { - useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { - getState - }) - } -}) - -vi.mock('@/components/ui/hover-card', () => ({ - HoverCard: ({ children, openDelay }: { children: ReactNode; openDelay?: number }) => ( -
{children}
- ), - HoverCardContent: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - HoverCardTrigger: ({ children }: { children: ReactNode }) => - React.isValidElement(children) ? ( - React.cloneElement(children as React.ReactElement>, { - 'data-hover-card-trigger': '' - }) - ) : ( - <>{children} - ) -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - activateWorktreeFromSidebar: vi.fn() -})) - -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'active' -})) - -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => false -})) - -vi.mock('../activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt -})) - -vi.mock('../activity/useWorktreeAgentRows', () => ({ - useWorktreeAgentRows: vi.fn(() => mockInlineAgentRows) -})) - -vi.mock('../activity/WorktreeCardAgents', () => ({ - default: ({ className, agents }: { className?: string; agents?: DashboardAgentRowData[] }) => ( -
- ) -})) - -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: '/repo', - displayName: 'orca', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo/worktrees/pr-456', - repoId: 'repo-1', - path: '/repo/worktrees/pr-456', - displayName: 'Fix stale GH PR', - branch: 'feature/local-branch', - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - ...overrides - } -} - -function makeHostedReview(overrides: Partial = {}): HostedReviewInfo { - return { - provider: 'github', - number: 456, - title: 'Fix stale GH PR', - state: 'open', - url: 'https://github.com/acme/orca/pull/456', - status: 'success', - updatedAt: '2026-05-17T00:00:00.000Z', - mergeable: 'MERGEABLE', - ...overrides - } -} - -function expectIdentityBodyIsHoverTrigger(markup: string): void { - const surfaceTag = markup.match(/]*data-worktree-card-surface="true"[^>]*>/)?.[0] - const triggerTag = markup.match(/]*data-worktree-card-hover-trigger=""[^>]*>/)?.[0] - - expect(surfaceTag).toBeDefined() - expect(surfaceTag).not.toContain('data-hover-card-trigger=""') - expect(surfaceTag).not.toContain('group/worktree-card') - expect(triggerTag).toBeDefined() - expect(triggerTag).toContain('data-hover-card-trigger=""') - expect(triggerTag).toContain('group/worktree-card') -} - -describe('WorktreeCard compact hover details', () => { - beforeEach(() => { - vi.clearAllMocks() - worktreeCardProperties = ['status', 'ports'] - hostedReviewCache = {} - issueCache = {} - projectGroups = [] - workspacePortScan = null - settings = { compactWorktreeCards: true } - agentActivityDisplayMode = undefined - mockInlineAgentRows = [] - cacheTimerMocks.usePromptCacheCountdownStartedAt.mockReturnValue(null) - }) - - it('shows PR and live port details from the compact worktree card hover', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - const worktree = makeWorktree({ linkedPR: 456 }) - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview(), - fetchedAt: Date.now() - } - } - workspacePortScan = { - key: 'repo-1', - result: { - platform: 'darwin', - scannedAt: 1, - ports: [ - { - id: '127.0.0.1:58941:1234', - bindHost: '127.0.0.1', - connectHost: '127.0.0.1', - port: 58941, - pid: 1234, - processName: 'node', - protocol: 'http', - kind: 'workspace', - owner: { - worktreeId: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.path, - confidence: 'cwd' - } - } - ] - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-title-inline-rename=""') - expectIdentityBodyIsHoverTrigger(markup) - expect(markup).toContain('data-hover-open-delay="100"') - expect(markup).toContain('PR #456') - expect(markup).toContain('Fix stale GH PR') - expect(markup).toContain('Live Ports') - expect(markup).toContain('58941') - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('aria-label="1 live port"') - }, 30_000) - - it('shows hidden task, notes, and port details from the compact worktree card hover', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status'] - const worktree = makeWorktree({ - linkedIssue: 123, - linkedLinearIssue: 'ENG-123', - linkedPR: 456, - comment: 'Reviewer handoff note' - }) - workspacePortScan = { - key: 'repo-1', - result: { - platform: 'darwin', - scannedAt: 1, - ports: [ - { - id: '127.0.0.1:58941:1234', - bindHost: '127.0.0.1', - connectHost: '127.0.0.1', - port: 58941, - pid: 1234, - processName: 'node', - protocol: 'http', - kind: 'workspace', - owner: { - worktreeId: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.path, - confidence: 'cwd' - } - } - ] - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-hover-open-delay="100"') - expectIdentityBodyIsHoverTrigger(markup) - expect(markup).toContain('Issue #123') - expect(markup).toContain('ENG-123') - expect(markup).toContain('aria-label="Linear"') - expect(markup).toContain('Reviewer handoff note') - expect(markup).toContain('Live Ports') - expect(markup).toContain('58941') - expect(markup).not.toContain('data-worktree-card-meta-row=""') - }, 30_000) - - it('reads linked issue details from the local repo-owner cache while a runtime is focused', async () => { - settings = { - activeRuntimeEnvironmentId: 'env-1', - compactWorktreeCards: true, - experimentalNewWorktreeCardStyle: true - } - worktreeCardProperties = ['status'] - issueCache = { - 'repo-1::123': { - data: { number: 123, title: 'Local owner issue', state: 'open', url: null }, - fetchedAt: Date.now() - }, - 'runtime:env-1::repo-1::123': { - data: { number: 123, title: 'Runtime fallback issue', state: 'open', url: null }, - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Local owner issue') - expect(markup).not.toContain('Runtime fallback issue') - expect(markup).not.toContain('Loading issue') - }, 30_000) - - it('shows selected task and note metadata on the compact card title row', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('Linked issue #123') - expect(markup).toContain('Linked Linear ENG-123') - expect(markup).toContain('Workspace notes') - }, 30_000) - - it('keeps selected task and note metadata above the compact branch row', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch', 'issue', 'linear-issue', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - const issueIndex = markup.indexOf('Linked issue #123') - const branchRowIndex = markup.indexOf('data-worktree-card-meta-row=""') - - expect(issueIndex).toBeGreaterThanOrEqual(0) - expect(branchRowIndex).toBeGreaterThanOrEqual(0) - expect(issueIndex).toBeLessThan(branchRowIndex) - expect(markup).toContain('Linked Linear ENG-123') - expect(markup).toContain('Workspace notes') - expect(markup).toContain('feature/local-branch') - }, 30_000) - - it('keeps branch identity visible on detailed cards by default', async () => { - settings = { compactWorktreeCards: false } - worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-hover-open-delay="100"') - expect(markup).toContain('feature/local-branch') - expect(markup).toContain('Human title') - }) - - it('uses one identity hover even when detailed metadata icons are visible when new card style is on', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Workspace metadata') - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expectIdentityBodyIsHoverTrigger(markup) - expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1) - expect(markup).toContain('Reviewer handoff note') - }) - - it('keeps long workspace and branch identity in hover details when the branch row is hidden', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expectIdentityBodyIsHoverTrigger(markup) - expect(markup).toContain('[Bug]: Hold-to-talk speech-to-text option no longer works') - expect(markup).toContain('bug-hold-to-talk-speech-to-text-option-no-longer-works') - expect(markup).toContain('Reviewer handoff note') - }) - - it('repeats a long workspace title inside the identity hover when branch is already visible', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch', 'comment'] - const longTitle = - 'Investigate why the worktree hover card title disappears behind single-line truncation' - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expectIdentityBodyIsHoverTrigger(markup) - expect(markup.match(new RegExp(longTitle, 'g'))).toHaveLength(2) - expect(markup).toContain('feature/local-branch') - expect(markup).toContain('Reviewer handoff note') - }) - - it('uses identity hover for identity-only new card worktrees with branch row visible', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-meta-row=""') - expectIdentityBodyIsHoverTrigger(markup) - expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1) - expect(markup.match(/Readable identity only/g)).toHaveLength(2) - expect(markup).toContain('feature/local-branch') - expect(markup).not.toContain('Workspace metadata') - expect(markup).not.toContain('Live Ports') - }) - - it('does not duplicate workspace identity when trimmed title equals branch', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expectIdentityBodyIsHoverTrigger(markup) - expect(markup.match(/feature\/local-branch/g)).toHaveLength(3) - }) - - it('keeps detailed metadata hover scoped to metadata icons by default', async () => { - settings = { compactWorktreeCards: false } - worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-meta-row=""') - const surfaceTag = markup.match(/]*data-worktree-card-surface="true"[^>]*>/)?.[0] - expect(surfaceTag).toBeDefined() - expect(surfaceTag).not.toContain('data-hover-card-trigger=""') - expect(markup.match(/data-hover-open-delay="250"/g)).toHaveLength(1) - expect(markup).toContain('Reviewer handoff note') - }) - - it('keeps child card markup inside the parent card by default', async () => { - settings = { compactWorktreeCards: false } - worktreeCardProperties = ['status', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - Child card
} - /> - ) - const childIndex = markup.indexOf('data-lineage-child-card=""') - - const surfaceTag = markup.match(/]*data-worktree-card-surface="true"[^>]*>/)?.[0] - expect(surfaceTag).toBeDefined() - expect(surfaceTag).not.toContain('data-hover-card-trigger=""') - expect(markup).toContain('data-worktree-lineage-children=""') - expect(childIndex).toBeGreaterThanOrEqual(0) - }) - - it('suppresses inline agent rows in compact cards by default', async () => { - settings = { compactWorktreeCards: true } - worktreeCardProperties = ['status', 'inline-agents'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-agents') - }) - - it('does not create a compact metadata row solely for an aggregate cache timer', async () => { - settings = { compactWorktreeCards: true } - worktreeCardProperties = ['status'] - cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation( - (_worktreeId: string, active = true) => (active ? 10_000 : null) - ) - const worktree = makeWorktree() - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith( - worktree.id, - false - ) - expect(markup).not.toContain('data-worktree-card-meta-row=""') - }) - - it('keeps unlink available for an auto-detected PR in the identity hover', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - const worktree = makeWorktree() - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ url: '' }), - fetchedAt: Date.now(), - linkedReviewHintKey: 'github:456', - branchLookupGitHubPRNumber: 456 - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('More PR actions') - }) - - it('suppresses the aggregate cache timer when compact inline agents are visible', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'inline-agents'] - agentActivityDisplayMode = 'compact' - mockInlineAgentRows = [{} as DashboardAgentRowData] - const worktree = makeWorktree() - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-agents=""') - expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith( - worktree.id, - false - ) - }) - - it('keeps status and agent tooltip targets outside the worktree details hover trigger', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'inline-agents'] - agentActivityDisplayMode = 'compact' - mockInlineAgentRows = [{} as DashboardAgentRowData] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - const statusIndex = markup.indexOf('data-worktree-card-status-slot=""') - const triggerIndex = markup.indexOf('data-worktree-card-hover-trigger=""') - const hoverContentIndex = markup.indexOf('data-hover-card-content=""') - const agentsIndex = markup.indexOf('data-worktree-agents=""') - - expectIdentityBodyIsHoverTrigger(markup) - expect(statusIndex).toBeGreaterThanOrEqual(0) - expect(statusIndex).toBeLessThan(triggerIndex) - expect(hoverContentIndex).toBeGreaterThan(triggerIndex) - expect(agentsIndex).toBeGreaterThan(hoverContentIndex) - }) - - it('preserves the aggregate cache timer when compact inline agents are enabled but absent', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'inline-agents'] - agentActivityDisplayMode = 'compact' - mockInlineAgentRows = [] - cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation( - (_worktreeId: string, active = true) => (active ? 10_000 : null) - ) - const worktree = makeWorktree() - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-agents=""') - expect(markup).toContain('data-agent-count="0"') - expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(worktree.id, true) - }) - - it('keeps child card markup outside the parent hover trigger when new card style is on', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - Child card
} - /> - ) - const surfaceIndex = markup.indexOf('data-worktree-card-surface="true"') - const triggerIndex = markup.indexOf('data-worktree-card-hover-trigger=""') - const hoverContentIndex = markup.indexOf('data-hover-card-content=""') - const childIndex = markup.indexOf('data-lineage-child-card=""') - - expectIdentityBodyIsHoverTrigger(markup) - expect(markup).toContain('data-worktree-lineage-children=""') - expect(markup).toContain('group/worktree-card') - expect(markup).not.toContain('group relative flex cursor-pointer') - expect(markup).not.toContain('group/worktree-card relative flex cursor-pointer') - expect(surfaceIndex).toBeGreaterThanOrEqual(0) - expect(triggerIndex).toBeGreaterThan(surfaceIndex) - expect(hoverContentIndex).toBeGreaterThanOrEqual(0) - expect(childIndex).toBeGreaterThan(hoverContentIndex) - }) - - it('uses a centered parent row and raised title size when no meta row is visible', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('data-worktree-card-parent-content=""') - expect(markup).toContain('items-center') - expect(markup).toContain('w-5 items-center') - expect(markup).toContain('text-[13px] leading-5') - }) - - it('does not show a folder path row in new-card mode when no project groups exist', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('Docs folder') - expect(markup).not.toContain('/Users/x/projects/my-app') - }) - - it('shows a folder path row in new-card mode through the branch setting when project groups exist', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch'] - projectGroups = [{ id: 'group-1' }] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-meta-row=""') - expect(markup).toContain(' /Users/x/projects/my-app ') - expect(markup).not.toContain('>Folder') - }) - - it('keeps hidden folder path identity available from the new-card hover', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status'] - projectGroups = [{ id: 'group-1' }] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('data-hover-open-delay="100"') - expect(markup).toContain('/Users/x/projects/my-app') - expect(markup).toContain('Docs folder') - }) - - it('shows the branch row for migrated Default cards with branch enabled', async () => { - settings = { compactWorktreeCards: false } - worktreeCardProperties = ['status', 'branch'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('feature/local-branch') - }) - - it('keeps compact card branch hidden in the row but available from title hover by default', async () => { - settings = { compactWorktreeCards: true } - worktreeCardProperties = ['status', 'branch'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('data-hover-open-delay="100"') - expect(markup).toContain('feature/local-branch') - }) - - it('shows the branch row for compact cards when branch is enabled and new card style is on', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('feature/local-branch') - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx deleted file mode 100644 index f63ada5a5f8..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx +++ /dev/null @@ -1,893 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' -import type { PRInfo } from '../../../../../../shared/github/pull-request-types' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { COMPACT_WORKTREE_CARD_PROPERTIES } from '../../../../../../shared/worktree/card-properties' -import type { WorkspacePortScanResult } from '../../../../../../shared/workspace-ports' - -const fetchHostedReviewForBranch = vi.fn() -const fetchIssue = vi.fn() -const fetchLinearIssue = vi.fn() -const openModal = vi.fn() -const updateWorktreeMeta = vi.fn() - -let worktreeCardProperties: WorktreeCardProperty[] = ['status'] -let hostedReviewCache: Record = {} -let issueCache: Record = {} -let prCache: Record = {} -let workspacePortScan: WorkspacePortScanResult | null = null -let settings: Partial | null = null - -vi.mock('@/store', () => { - const getState = () => ({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache, - issueCache, - linearIssueCache: {}, - openModal, - prCache, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - updateWorktreeMeta, - workspacePortScan, - worktreeCardProperties - }) - return { - useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { - getState - }) - } -}) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'active' -})) - -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => false -})) - -vi.mock('../activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: () => null -})) - -vi.mock('../activity/WorktreeCardAgents', () => ({ - default: () => null -})) - -vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({ - default: ({ children }: { children: ReactNode }) => <>{children}, - WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', - WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' -})) - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/repo', - displayName: 'orca', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo/worktrees/pr-456', - repoId: 'repo-1', - path: '/repo/worktrees/pr-456', - displayName: 'Fix stale GH PR', - branch: 'feature/local-branch', - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - ...overrides - } -} - -function makeHostedReview(overrides: Partial = {}): HostedReviewInfo { - return { - provider: 'github', - number: 456, - title: 'Fix stale GH PR', - state: 'open', - url: 'https://github.com/acme/orca/pull/456', - status: 'success', - updatedAt: '2026-05-17T00:00:00.000Z', - mergeable: 'MERGEABLE', - ...overrides - } -} - -function makePRInfo(overrides: Partial = {}): PRInfo { - return { - number: 456, - title: 'Fix stale GH PR', - state: 'open', - url: 'https://github.com/acme/orca/pull/456', - checksStatus: 'success', - updatedAt: '2026-05-17T00:00:00.000Z', - mergeable: 'MERGEABLE', - ...overrides - } -} - -function renderWorktreeCardMarkup(element: ReactNode): string { - return renderToStaticMarkup(<>{element}) -} - -function countAutomationCreatedLabels(markup: string): number { - return markup.match(/Created by automation/g)?.length ?? 0 -} - -function getInlineRenameTitleTag(markup: string): string { - const match = markup.match(/]*data-worktree-title-inline-rename=""[^>]*>/) - expect(match).not.toBeNull() - return match?.[0] ?? '' -} - -describe('WorktreeCard linked PR display', () => { - beforeEach(() => { - vi.clearAllMocks() - worktreeCardProperties = ['status'] - hostedReviewCache = {} - issueCache = {} - prCache = {} - workspacePortScan = null - settings = null - }) - - it('keeps linked GH PR status out of the left status slot by default', async () => { - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR: Open') - expect(markup).not.toContain('Linked PR #456') - }, 20_000) - - it('keeps compact toggle-off unread and read-title visuals legacy', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: false } - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ status: 'failure' }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const unreadMarkup = renderWorktreeCardMarkup( - - ) - const readMarkup = renderWorktreeCardMarkup( - - ) - const readTitleTag = getInlineRenameTitleTag(readMarkup) - - expect(unreadMarkup).toContain('aria-label="Mark as read"') - expect(unreadMarkup).toContain('text-amber-500') - expect(unreadMarkup).not.toContain('PR checks: Failed · Mark read') - expect(unreadMarkup).not.toContain('size-[13px] translate-x-px') - expect(readTitleTag).toContain('font-normal text-foreground') - expect(readTitleTag).not.toContain('text-foreground/80') - }, 20_000) - - it('applies experimental unread status and read-title visuals only when enabled', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ status: 'failure' }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const unreadMarkup = renderWorktreeCardMarkup( - - ) - const readMarkup = renderWorktreeCardMarkup( - - ) - - expect(unreadMarkup).not.toContain('aria-label="Mark as read"') - expect(unreadMarkup).toContain('PR checks: Failed · Unread') - expect(unreadMarkup).not.toContain('Mark read') - expect(unreadMarkup).toContain('size-[13px] translate-x-px') - expect(unreadMarkup).not.toContain('lucide-bell') - expect(unreadMarkup).not.toContain('text-amber-500') - expect(unreadMarkup).toContain('data-worktree-status-lane-unread=""') - expect(unreadMarkup).toContain('data-worktree-unread-alert=""') - expect(unreadMarkup).toContain('bg-amber-500') - expect(getInlineRenameTitleTag(unreadMarkup)).toContain('font-semibold text-foreground') - expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80') - }, 20_000) - - it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('PR: Open') - expect(markup).not.toContain('Linked PR #456') - }, 20_000) - - it('does not show cached branch PR details when the worktree has no linked PR', async () => { - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ number: 456, title: 'Stale branch PR' }), - fetchedAt: Date.now(), - linkedReviewHintKey: 'github:456' - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ number: 456, title: 'Stale branch PR' }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).not.toContain('PR #456') - expect(markup).not.toContain('Stale branch PR') - }) - - it('shows branch-discovered GH PR status when the worktree has no linked PR', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ number: 456, title: 'Branch PR', state: 'open' }), - fetchedAt: Date.now(), - linkedReviewHintKey: 'github:456', - branchLookupGitHubPRNumber: 456 - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ number: 456, title: 'Branch PR' }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('PR checks: Passing') - expect(markup).toContain('text-emerald-500/80') - expect(markup).not.toContain('Branch') - }) - - it('shows branch-discovered hosted review providers without linked worktree metadata', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ - provider: 'bitbucket', - number: 789, - title: 'Bitbucket branch PR', - url: 'https://bitbucket.org/acme/orca/pull-requests/789' - }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('PR checks: Passing') - expect(markup).not.toContain('Linked PR #789') - }) - - it('keeps the stored branch title by default when a hosted review title is available', async () => { - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview(), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('data-worktree-title-inline-rename=""') - expect(markup).toContain('>feature/local-branch') - expect(markup).not.toContain('>Fix stale GH PR') - }) - - it('uses the hosted review title when new card style is on and stored title is the branch', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview(), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('data-worktree-title-inline-rename=""') - expect(markup).toContain('>Fix stale GH PR') - expect(markup).not.toContain('>feature/local-branch') - }) - - it('shows task and notes metadata while keeping PR out of the right metadata list', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('Linked issue #123') - expect(markup).toContain('Linked Linear ENG-123') - expect(markup).toContain('PR: Open') - expect(markup).not.toContain('Linked PR #456') - expect(markup).toContain('Workspace notes') - expect(markup).not.toContain('data-slot="badge"') - expect(markup).not.toContain('Loading issue') - expect(markup).not.toContain('Reviewer handoff note') - }) - - it('shows selected task and notes metadata on compact cards when new card style is on', async () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('Linked issue #123') - expect(markup).toContain('Linked Linear ENG-123') - expect(markup).not.toContain('Linked PR #456') - expect(markup).toContain('Workspace notes') - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).not.toContain('Reviewer handoff note') - }) - - it('hides individual metadata surfaces when their card properties are disabled', async () => { - worktreeCardProperties = [] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).not.toContain('Linked issue #123') - expect(markup).not.toContain('Linked Linear ENG-123') - expect(markup).not.toContain('Linked PR #456') - expect(markup).not.toContain('Workspace notes') - expect(markup).not.toContain('Reviewer handoff note') - }) - - it('shows automation-created workspaces as a metadata icon property', async () => { - worktreeCardProperties = ['status', 'automation'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(countAutomationCreatedLabels(markup)).toBe(1) - expect(markup).not.toContain('>Automation') - }, 20_000) - - it('hides the automation metadata icon in compact card mode by preset default', async () => { - settings = { compactWorktreeCards: true } - worktreeCardProperties = [...COMPACT_WORKTREE_CARD_PROPERTIES] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(countAutomationCreatedLabels(markup)).toBe(0) - expect(markup).not.toContain('>Automation') - expect(markup).not.toContain('Nightly triage automation') - }, 20_000) - - it('shows the automation metadata icon in compact card mode when manually enabled', async () => { - settings = { compactWorktreeCards: true } - worktreeCardProperties = ['status', 'automation'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(countAutomationCreatedLabels(markup)).toBe(1) - expect(markup).not.toContain('>Automation') - }, 20_000) - - it('hides automation-created card surfaces when the Automation property is disabled', async () => { - worktreeCardProperties = ['status'] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(countAutomationCreatedLabels(markup)).toBe(0) - expect(markup).not.toContain('>Automation') - expect(markup).not.toContain('Nightly triage automation') - }) - - it('hides live port metadata when the Ports card property is disabled', async () => { - const worktree = makeWorktree() - workspacePortScan = { - platform: 'darwin', - scannedAt: 1, - ports: [ - { - id: '127.0.0.1:58941:1234', - bindHost: '127.0.0.1', - connectHost: '127.0.0.1', - port: 58941, - pid: 1234, - processName: 'node', - protocol: 'http', - kind: 'workspace', - owner: { - worktreeId: worktree.id, - repoId: worktree.repoId, - displayName: worktree.displayName, - path: worktree.path, - confidence: 'cwd' - } - } - ] - } - worktreeCardProperties = [] - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).not.toContain('live port') - expect(markup).not.toContain('Live Ports') - expect(markup).not.toContain('58941') - }) - - it('renders linked PR status in the left status slot instead of the right metadata list', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status'] - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: makeHostedReview({ status: 'failure' }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('text-rose-500/85') - expect(markup).not.toContain('Linked PR #456') - expect(markup).not.toContain('CI checks') - }) - - it('uses branch PR cache for the status slot before hosted-review metadata warms', async () => { - settings = { experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status'] - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6340, - title: 'Remove split terminal from onboarding checklist', - state: 'merged', - headSha: 'abc123', - checksStatus: 'success' - }), - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('PR: Merged') - expect(markup).toContain('text-purple-600/70') - expect(markup).not.toContain('Branch') - expect(markup).not.toContain('lucide-git-branch') - }) - - it('reads the local branch PR cache for a known local repo while a runtime is focused', async () => { - settings = { - activeRuntimeEnvironmentId: 'env-win', - experimentalNewWorktreeCardStyle: true - } - worktreeCardProperties = ['status'] - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6341, - title: 'Keep local PR status visible', - state: 'open', - checksStatus: 'pending' - }), - fetchedAt: Date.now() - }, - 'runtime:env-win::repo-1::feature/local-branch': { - data: null, - fetchedAt: Date.now() - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('PR checks: Pending') - expect(markup).not.toContain('Branch') - expect(markup).not.toContain('lucide-git-branch') - }) - - it('keeps the detailed right-side PR badge during a transient hosted-review miss', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } - worktreeCardProperties = ['pr'] - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: null, - fetchedAt: 100 - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6340, - title: 'Remove split terminal from onboarding checklist', - state: 'open', - checksStatus: 'success' - }), - fetchedAt: 200 - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('Linked PR #6340') - expect(markup).not.toContain('Linked PR #456') - }) - - it('keeps the detailed PR badge when a transient miss still has an older review hint', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } - worktreeCardProperties = ['pr'] - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: null, - fetchedAt: 100, - linkedReviewHintKey: 'github:999' - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6340, - title: 'Remove split terminal from onboarding checklist', - state: 'open', - checksStatus: 'success' - }), - fetchedAt: 200 - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('Linked PR #6340') - expect(markup).not.toContain('Linked PR #456') - }) - - it('keeps durable non-GitHub linked review metadata ahead of branch PR cache', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } - worktreeCardProperties = ['pr'] - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: null, - fetchedAt: 100 - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6340, - title: 'Remove split terminal from onboarding checklist', - state: 'open', - checksStatus: 'success' - }), - fetchedAt: 200 - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).toContain('Linked MR #77') - expect(markup).not.toContain('Linked PR #6340') - }) - - it('does not resurrect an older PR cache entry after a newer hosted-review miss', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } - worktreeCardProperties = ['pr'] - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: null, - fetchedAt: 200 - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6340, - title: 'Remove split terminal from onboarding checklist', - state: 'open', - checksStatus: 'success' - }), - fetchedAt: 100 - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).not.toContain('Linked PR #6340') - }) - - it('does not resurrect PR cache on the same millisecond as a hosted-review miss', async () => { - settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false } - worktreeCardProperties = ['pr'] - hostedReviewCache = { - 'local::repo-1::feature/local-branch': { - data: null, - fetchedAt: 200 - } - } - prCache = { - 'repo-1::feature/local-branch': { - data: makePRInfo({ - number: 6340, - title: 'Remove split terminal from onboarding checklist', - state: 'open', - checksStatus: 'success' - }), - fetchedAt: 200 - } - } - const { default: WorktreeCard } = await import('./WorktreeCard') - - const markup = renderWorktreeCardMarkup( - - ) - - expect(markup).not.toContain('Linked PR #6340') - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.quick-actions.test.tsx deleted file mode 100644 index b16d45d1b01..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.quick-actions.test.tsx +++ /dev/null @@ -1,610 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types' -import type { GlobalSettings } from '../../../../../../shared/global-settings-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type WorktreeCardComponent from './WorktreeCard' -import type * as WorkspaceDeleteQuickAction from '../../workspace-actions/deletion/workspace-delete-quick-action' - -const fetchHostedReviewForBranch = vi.fn() -const fetchIssue = vi.fn() -const openModal = vi.fn() -const updateWorktreeMeta = vi.fn() - -let worktreeCardProperties: WorktreeCardProperty[] = ['status'] -let tabsByWorktree: Record = {} -let ptyIdsByTabId: Record = {} -let browserTabsByWorktree: Record = {} -let settings: Partial | null = null -let projectGroups: unknown[] = [] -let workspaceDeleteModifierPressed = false -let gitConflictOperationByWorktree: Record = {} -let WorktreeCard: typeof WorktreeCardComponent - -vi.mock('@/store', () => { - const getState = () => ({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - gitConflictOperationByWorktree, - hostedReviewCache: {}, - issueCache: {}, - openModal, - projectGroups, - remoteBranchConflictByWorktreeId: {}, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - browserTabsByWorktree, - ptyIdsByTabId, - tabsByWorktree, - updateWorktreeMeta, - worktreeCardProperties - }) - return { - useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { - getState, - subscribe: () => () => {} - }) - } -}) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'idle' -})) - -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => false -})) - -vi.mock('../activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: () => null -})) - -vi.mock('../activity/WorktreeCardAgents', () => ({ - default: () => null -})) - -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' -})) - -vi.mock( - '../../workspace-actions/deletion/workspace-delete-quick-action', - async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useWorkspaceDeleteModifierPressed: () => workspaceDeleteModifierPressed - } - } -) - -function makeRepo(): Repo { - return { - id: 'repo-1', - path: '/repo', - displayName: 'orca', - badgeColor: '#999999', - addedAt: 1 - } -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'repo-1::/repo/worktrees/quick-action', - repoId: 'repo-1', - path: '/repo/worktrees/quick-action', - displayName: 'Quick action', - branch: 'quick-action', - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: true, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - ...overrides - } -} - -function getBranchMetadataLabelTag(markup: string): string { - const labelTag = markup - .match(/]*>quick-action<\/span>/g) - ?.find((tag) => tag.includes('text-[11px]')) - - expect(labelTag).toBeDefined() - return labelTag ?? '' -} - -describe('WorktreeCard quick actions', () => { - beforeAll(async () => { - WorktreeCard = (await import('./WorktreeCard')).default - }, 20_000) - - beforeEach(() => { - vi.clearAllMocks() - worktreeCardProperties = ['status'] - tabsByWorktree = {} - ptyIdsByTabId = {} - browserTabsByWorktree = {} - settings = null - projectGroups = [] - workspaceDeleteModifierPressed = false - gitConflictOperationByWorktree = {} - }) - - it('marks the unread toggle as a workspace-board-preserving action', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Mark as read"') - expect(markup).toContain('data-workspace-board-preserve-open=""') - }) - - it('renders repo identity in the detailed metadata row', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Project orca"') - expect(markup).toContain('>orca') - expect(markup).toContain('data-worktree-card-meta-row=""') - }) - - it('can render the current workspace with a secondary active surface', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-active="secondary"') - expect(markup).not.toContain('bg-black/[0.08]') - expect(markup).not.toContain('dark:bg-white/[0.10]') - expect(markup).not.toContain('bg-sidebar-accent/45') - expect(markup).not.toContain('border-sidebar-ring/25') - expect(markup).not.toContain('ring-sidebar-ring/15') - }) - - it('marks the primary active workspace for token-driven selected styling', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('data-worktree-card-active="primary"') - expect(markup).toContain('data-worktree-card-surface="true"') - expect(markup).not.toContain('bg-black/[0.08]') - expect(markup).not.toContain('dark:bg-white/[0.10]') - expect(markup).not.toContain('border-black/[0.015]') - }) - - it('marks multi-selected workspaces for the shared selected style, except the active one', () => { - const selected = renderToStaticMarkup( - - ) - const activeSelected = renderToStaticMarkup( - - ) - - expect(selected).toContain('data-worktree-card-selected="true"') - expect(activeSelected).not.toContain('data-worktree-card-selected') - }) - - it('renders folder directory name in the detailed metadata row without a Folder badge', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Docs folder') - expect(markup).not.toContain('>Folder') - expect(markup).toContain('>quick-action') - expect(markup).toContain('data-worktree-card-meta-row=""') - }) - - it('renders synthetic folder workspace directory name in the detailed metadata row without a Folder badge', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Docs folder') - expect(markup).not.toContain('>Folder') - expect(markup).toContain('>quick-action') - expect(markup).toContain('data-worktree-card-meta-row=""') - }) - - it('does not render a branch icon for synthetic folder workspace path identity', () => { - settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status', 'branch'] - projectGroups = [{ id: 'project-group-1' }] - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('/repo/worktrees/quick-action') - expect(markup).not.toContain('lucide-git-branch') - }) - - it('does not render a pending first-agent rename title badge', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toMatch(/Will be renamed from first agent message|rename pending/) - expect(markup).not.toContain( - 'aria-label="This worktree will be renamed from the first agent message"' - ) - expect(markup).not.toContain('rename pending') - expect(markup).not.toContain('This worktree will be renamed from the first agent message') - }) - - it('renders the failed first-agent rename title badge', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Auto-rename failed: view error"') - expect(markup).toContain('rename failed') - }) - - it('renders the failed first-agent rename title badge when rename is also pending', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Auto-rename failed: view error"') - expect(markup).toContain('rename failed') - expect(markup).not.toContain('rename pending') - }) - - it('renders the migrated branch metadata row when branch is enabled', () => { - worktreeCardProperties = ['branch'] - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('quick-action') - const branchLabelTag = getBranchMetadataLabelTag(markup) - expect(branchLabelTag).toContain('truncate') - expect(branchLabelTag).toContain('text-[11px]') - expect(branchLabelTag).toContain('text-muted-foreground') - expect(branchLabelTag).toContain('leading-none') - expect(markup).toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('tabindex="0"') - }) - - it('renders detached HEAD identity in detailed card metadata', () => { - worktreeCardProperties = [] - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('orca') - expect(markup).toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('Detached HEAD @ abc123') - expect(markup).toContain('Detached HEAD at abc123. You are viewing a commit, not a branch.') - expect(markup).toContain('tabindex="0"') - }) - - it('omits the repeated branch metadata row when compact cards are enabled', () => { - worktreeCardProperties = [] - settings = { compactWorktreeCards: true } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).toContain('tabindex="0"') - }) - - it('omits the branch metadata row when the workspace has a custom title', () => { - worktreeCardProperties = [] - settings = { compactWorktreeCards: true } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Custom workspace') - expect(markup).not.toContain('>quick-action<') - expect(markup).not.toContain('data-worktree-card-meta-row=""') - expect(markup).not.toContain('text-[11px] text-muted-foreground truncate leading-none') - }) - - it('uses the left status lane and primary badge when compact cards are disabled', () => { - worktreeCardProperties = ['status'] - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('primary') - expect(markup).toContain('>sparse<') - expect(markup).not.toContain('aria-label="Primary worktree"') - expect(markup).not.toContain('aria-label="Sparse checkout"') - expect(markup).toContain('data-worktree-card-meta-row=""') - }) - - it('keeps unread in the status lane and moves primary into the title row when compact cards are enabled', () => { - worktreeCardProperties = ['status'] - settings = { compactWorktreeCards: true } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Primary worktree"') - expect(markup).not.toContain('>primary<') - expect(markup).not.toContain('data-worktree-card-meta-row=""') - }) - - it.each([ - [true, true], - [true, false], - [false, true], - [false, false] - ])('shows applicable new-style identity icons (primary: %s, sparse: %s)', (primary, sparse) => { - settings = { experimentalNewWorktreeCardStyle: true } - const markup = renderToStaticMarkup( - - ) - - expect(markup.includes('aria-label="Primary worktree"')).toBe(primary) - expect(markup.includes('aria-label="Sparse checkout"')).toBe(sparse) - expect(markup.includes('lucide-house')).toBe(primary) - expect(markup.includes('lucide-folder-minus')).toBe(sparse) - expect(markup).not.toContain('>primary<') - expect(markup).not.toContain('>sparse<') - if (sparse) { - expect(markup).toContain('src/renderer') - } - }) - - it('does not show the primary icon for a folder workspace', () => { - settings = { experimentalNewWorktreeCardStyle: true } - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Primary worktree"') - }) - - it('hides delete by default for an inactive workspace', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Delete workspace"') - }) - - it('shows delete as the top-right quick action while Option/Alt is held', () => { - workspaceDeleteModifierPressed = true - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Delete workspace"') - }) - - it('shows delete as the quick action for folder workspace instances while Option/Alt is held', () => { - workspaceDeleteModifierPressed = true - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Delete workspace"') - }) - - it('shows delete for a current workspace while Option/Alt is held', () => { - workspaceDeleteModifierPressed = true - const worktree = makeWorktree() - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Delete workspace"') - }) - - it('does not show delete for the main worktree while Option/Alt is held', () => { - workspaceDeleteModifierPressed = true - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Delete workspace"') - }) - - it('does not replace sleep with delete for a workspace with live activity', () => { - const worktree = makeWorktree() - tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] } - ptyIdsByTabId = { 'tab-1': ['pty-1'] } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Sleep workspace"') - expect(markup).not.toContain('aria-label="Delete workspace"') - }) - - it('does not show sleep as the top-right quick action for an active workspace', () => { - const worktree = makeWorktree() - tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] } - ptyIdsByTabId = { 'tab-1': ['pty-1'] } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Sleep workspace"') - expect(markup).not.toContain('aria-label="Delete workspace"') - }) - - it('does not show delete when the workspace is current but not selected in the sidebar', () => { - const worktree = makeWorktree() - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Delete workspace"') - }) - - it('does not show the rebase operation chip on the card', () => { - const worktree = makeWorktree() - gitConflictOperationByWorktree = { [worktree.id]: 'rebase' } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('Rebasing') - expect(markup).toContain('data-worktree-card-meta-row=""') - }) - - it('keeps non-rebase operation chips on the card', () => { - const worktree = makeWorktree() - gitConflictOperationByWorktree = { [worktree.id]: 'merge' } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Merging') - expect(markup).toContain('data-worktree-card-meta-row=""') - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.ssh-reconnect-prompt.test.tsx deleted file mode 100644 index f1c085fb4c9..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../../../shared/repo-types' -import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type WorktreeCardComponent from './WorktreeCard' - -const fetchHostedReviewForBranch = vi.fn() -const fetchIssue = vi.fn() -const fetchLinearIssue = vi.fn() -const openModal = vi.fn() -const updateWorktreeMeta = vi.fn() - -let WorktreeCard: typeof WorktreeCardComponent -let sshConnectionStates = new Map() -let sshTargetLabels = new Map() -let removedSshTargetLabels = new Map() -let runtimeStatusByEnvironmentId = new Map() -let runtimeEnvironments: { id: string; name: string }[] = [] -let sshStateByEnvironment = new Map() -let worktreesByRepo: Record = {} -let worktreeCardProperties: WorktreeCardProperty[] = ['status'] - -vi.mock('@/store', () => { - const getState = () => ({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - removedSshTargetLabels, - settings: null, - sshConnectionStates, - sshStateByEnvironment, - sshTargetLabels, - sshTargetsHydrated: true, - updateWorktreeMeta, - worktreesByRepo, - worktreeCardProperties - }) - return { - useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { - getState, - subscribe: () => () => {} - }) - } -}) - -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('../activity/CacheTimer', () => ({ - default: () => null, - usePromptCacheCountdownStartedAt: () => null -})) - -vi.mock('../activity/WorktreeCardAgents', () => ({ - default: () => null -})) - -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'idle' -})) - -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => false -})) - -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: '/repo', - displayName: 'Remote repo', - badgeColor: '#999999', - addedAt: 1, - connectionId: 'ssh-target-1' - } -} - -function makeWorktree(): Worktree { - return { - id: 'worktree-1', - repoId: 'repo-1', - path: '/repo/worktrees/one', - displayName: 'Remote workspace', - branch: 'remote-workspace', - head: 'abc123', - isBare: false, - isMainWorktree: false, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1 - } -} - -describe('WorktreeCard SSH reconnect prompt', () => { - beforeAll(async () => { - WorktreeCard = (await import('./WorktreeCard')).default - }, 20_000) - - beforeEach(() => { - vi.clearAllMocks() - sshConnectionStates = new Map() - sshTargetLabels = new Map() - removedSshTargetLabels = new Map() - runtimeStatusByEnvironmentId = new Map() - runtimeEnvironments = [] - sshStateByEnvironment = new Map() - worktreesByRepo = {} - worktreeCardProperties = ['status'] - }) - - it('offers an inline reconnect control and never a blocking dialog for a disconnected SSH worktree', () => { - sshConnectionStates.set('ssh-target-1', { status: 'disconnected' }) - sshTargetLabels.set('ssh-target-1', 'Remote target') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Connect to SSH host Remote target') - expect(markup).toContain('Connect') - expect(markup).not.toContain('ssh-disconnected-dialog') - // Root opacity would also dim the reconnect tint/spinner that must remain visible. - expect(markup).not.toContain('opacity-60') - }) - - it('names the failure state in the control verb rather than a generic Connect', () => { - sshConnectionStates.set('ssh-target-1', { status: 'auth-failed' }) - sshTargetLabels.set('ssh-target-1', 'Remote target') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Reconnect SSH host Remote target') - expect(markup).toContain('authentication failed') - }) - - it('renders the passive host glyph, not a control, when the SSH host is connected', () => { - sshConnectionStates.set('ssh-target-1', { status: 'connected' }) - sshTargetLabels.set('ssh-target-1', 'Remote target') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Project on SSH host Remote target') - expect(markup).not.toContain('Connect to SSH host') - }) - - // connectionId is independent of workspace kind; SSH folders need the same reconnect control. - it('offers the control for a folder workspace on a disconnected SSH host', () => { - sshConnectionStates.set('ssh-target-1', { status: 'error' }) - sshTargetLabels.set('ssh-target-1', 'Remote target') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Retry SSH connection to Remote target') - }) - - // Runtime-owned SSH targets are excluded from listTargets; absence cannot establish removal. - it('never reports a runtime-owned SSH target as removed', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('was removed') - expect(markup).toContain('Project on SSH host') - }) - - it('reports a removed SSH host once the target list no longer carries it', () => { - sshConnectionStates.set('ssh-target-1', { status: 'error' }) - removedSshTargetLabels.set('ssh-target-1', 'Remote target (removed)') - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('was removed') - expect(markup).not.toContain('Retry SSH connection') - }) - - it('marks a runtime-host worktree disconnected once a probe finds it unreachable', () => { - runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] - runtimeStatusByEnvironmentId.set('env-1', { status: null }) - const runtimeRepo: Repo = { - ...makeRepo(), - connectionId: undefined, - executionHostId: 'runtime:env-1' - } - const markup = renderToStaticMarkup( - - ) - expect(markup).toContain('Remote Mac disconnected') - }) - - // Unprobed hosts must not appear disconnected/dimmed during launch. - it('leaves a runtime-host worktree undimmed before its first probe answers', () => { - runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] - const runtimeRepo: Repo = { - ...makeRepo(), - connectionId: undefined, - executionHostId: 'runtime:env-1' - } - const markup = renderToStaticMarkup( - - ) - expect(markup).not.toContain('Remote Mac disconnected') - expect(markup).toContain('Project on Remote Mac') - expect(markup).not.toContain('opacity-60') - }) - - it('distinguishes connected worktrees on different Orca servers', () => { - runtimeEnvironments = [ - { id: 'env-1', name: 'Remote Mac' }, - { id: 'env-2', name: 'Build Linux' } - ] - runtimeStatusByEnvironmentId.set('env-1', { status: { runtimeId: 'r1' } }) - runtimeStatusByEnvironmentId.set('env-2', { status: { runtimeId: 'r2' } }) - - const remoteMacMarkup = renderToStaticMarkup( - - ) - const buildLinuxMarkup = renderToStaticMarkup( - - ) - - expect(remoteMacMarkup).toContain('Project on Remote Mac') - expect(buildLinuxMarkup).toContain('Project on Build Linux') - }) - - it('reads nested SSH readiness from the owning HUB instead of client-local SSH state', () => { - runtimeStatusByEnvironmentId.set('hub-1', { status: { runtimeId: 'hub-runtime' } }) - sshConnectionStates.set('ssh-target-1', { status: 'disconnected' }) - sshTargetLabels.set('ssh-target-1', 'Misleading client-local target') - sshStateByEnvironment.set('hub-1', { - connectionStates: new Map([['ssh-target-1', { status: 'connected' }]]), - targetLabels: new Map([['ssh-target-1', 'HUB private target']]), - removedTargetLabels: new Map(), - targetsHydrated: true - }) - const worktree = { - ...makeWorktree(), - hostId: 'ssh:ssh-target-1' as const, - runtimeOwnerEnvironmentId: 'hub-1' - } - worktreesByRepo = { 'repo-1': [worktree] } - - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('SSH disconnected') - expect(markup).toContain('data-ssh-target-label="HUB private target"') - expect(markup).not.toContain('Misleading client-local target') - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.test.tsx deleted file mode 100644 index cc5dc3b792d..00000000000 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.test.tsx +++ /dev/null @@ -1,732 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { i18n } from '@/i18n/i18n' -import ko from '@/i18n/locales/ko.json' -import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' -import type { WorktreeCardPrDisplay } from '../review/worktree-card-pr-display' -import { summarizeWorkspaceReviewChecks } from '../../../../../../shared/workspace-review-checks' -import { getWorkspaceAttachmentKey } from '../../../../../../shared/workspace-attachments/workspace-attachment-normalization' -import { - referenceAttachment, - referenceReview -} from '../review/workspace-reference-fixtures.test-support' - -const mocks = vi.hoisted(() => ({ - status: 'active', - sleeping: false -})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => {children}, - TooltipContent: ({ children }: { children: ReactNode }) => ( - {children} - ), - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('../activity/use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => mocks.status -})) - -vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({ - useIsSleepingWorktree: () => mocks.sleeping -})) - -describe('WorktreeCardStatusSlot', () => { - afterEach(async () => { - await i18n.changeLanguage('en') - }) - beforeEach(() => { - mocks.status = 'active' - mocks.sleeping = false - }) - - const review: WorktreeCardPrDisplay = { - provider: 'github', - number: 123, - title: 'Review me', - state: 'open', - status: 'failure' - } - const gitlabReview: WorktreeCardPrDisplay = { - provider: 'gitlab', - number: 456, - title: 'Review me', - state: 'open', - status: 'pending' - } - - it.each([ - { - state: 'merged', - provider: 'github', - status: 'success', - icon: 'merge', - tone: 'text-purple-600/70', - label: 'State: Merged', - locale: 'en', - stale: false - }, - { - state: 'merged', - provider: 'github', - status: 'failure', - icon: 'merge', - tone: 'text-purple-600/70', - label: 'State: Merged', - locale: 'en', - stale: false - }, - { - state: 'draft', - provider: 'github', - status: 'success', - icon: 'pull-request-draft', - tone: 'text-muted-foreground/50', - label: 'Reviews: Draft', - locale: 'en', - stale: false - }, - { - state: 'closed', - provider: 'github', - status: 'success', - icon: 'pull-request-closed', - tone: 'text-muted-foreground/60', - label: 'State: Closed', - locale: 'en', - stale: false - }, - { - state: 'merged', - provider: 'github', - status: undefined, - icon: 'merge', - tone: 'text-purple-600/70', - label: 'State: Merged', - locale: 'en', - stale: true - }, - { - state: 'merged', - provider: 'github', - status: 'success', - icon: 'merge', - tone: 'text-purple-600/70', - label: ko.auto.components.sidebar.WorktreeCardMetadataStatusBadges.f394b3e86e, - locale: 'ko', - stale: false - }, - { - state: 'draft', - provider: 'gitlab', - status: 'success', - icon: 'pull-request-draft', - tone: 'text-muted-foreground/50', - label: 'Reviews: Draft', - locale: 'en', - stale: false - } - ] as const)( - 'preserves $provider $state collection lifecycle with $status checks ($locale, stale=$stale)', - async ({ state, provider, status, icon, tone, label, locale, stale }) => { - await i18n.changeLanguage(locale) - const items = [referenceAttachment(1), referenceAttachment(2)].map((item) => - provider === 'gitlab' - ? { - ...item, - provider, - type: 'mr' as const, - url: `https://gitlab.com/acme/orca/-/merge_requests/${item.number}` - } - : item - ) - const details = Object.fromEntries( - items.map((item) => [ - getWorkspaceAttachmentKey(item), - { - stale, - review: { ...referenceReview(item.number, { state }), provider, url: item.url, status } - } - ]) - ) - const markup = renderToStaticMarkup( - - ) - expect(markup).toContain(`lucide-git-${icon}`) - expect(markup).toContain(tone) - expect(markup).not.toContain('text-emerald-500') - expect(markup).toContain(label) - expect(markup).not.toContain('Draft PR') - if (stale) { - expect(markup).toContain('Last known status') - expect(markup).toContain('shimmer') - expect(markup).toContain('Checks: Unknown') - } - } - ) - - it('uses checks from every attached review rather than the legacy passing review', () => { - const items = [referenceAttachment(1), referenceAttachment(2)] - const details = Object.fromEntries( - items.map((item) => [ - getWorkspaceAttachmentKey(item), - { - review: referenceReview(item.number, { - status: item.number === 1 ? 'success' : 'failure' - }) - } - ]) - ) - const markup = renderToStaticMarkup( - - ) - expect(markup).toContain('Reviews: 2 · Checks: Failed') - expect(markup).toContain('State: Open') - expect(markup).toContain('text-emerald-500/80') - expect(markup).not.toContain('checks: Passing') - }) - it('keeps incomplete collection checks neutral instead of claiming passing', () => { - const items = [referenceAttachment(1), referenceAttachment(2)] - const markup = renderToStaticMarkup( - - ) - expect(markup).toContain('Reviews: 2 · Checks: Unknown') - expect(markup).toContain('text-muted-foreground') - expect(markup).not.toContain('text-emerald-500') - }) - it.each([{ items: [] }, { items: [referenceAttachment(1)] }])( - 'preserves discovered and singleton review lifecycle glyphs', - ({ items }) => { - const markup = renderToStaticMarkup( - - ) - expect(markup).toContain('PR: Draft') - expect(markup).toContain('lucide-git-pull-request-draft') - expect(markup).not.toContain('Reviews:') - } - ) - - it('lets the unread bell replace the visual status dot by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Mark as read"') - expect(markup).toContain('Mark as read') - expect(markup).not.toContain('Active · Mark as read') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).toContain('text-amber-500') - }) - - it('overlays an unread badge on the status dot when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Mark as read"') - expect(markup).not.toContain('Mark as read') - expect(markup).toContain('Active · Unread') - expect(markup).toContain('data-worktree-status-lane-unread=""') - expect(markup).toContain('data-worktree-unread-alert=""') - expect(markup).toContain('bg-amber-500') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('lucide-bell') - expect(markup).not.toContain('text-amber-500') - }) - - it('suppresses the new-card unread badge while unread status is working', () => { - mocks.status = 'working' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Working · Unread') - expect(markup).toContain('border-agent-working') - expect(markup).not.toContain('data-worktree-status-lane-unread=""') - expect(markup).not.toContain('data-worktree-unread-alert=""') - expect(markup).not.toContain('aria-label="Mark as read"') - expect(markup).not.toContain('lucide-bell') - expect(markup).not.toContain('text-amber-500') - }) - - it('suppresses the new-card unread badge while unread status is permission', () => { - mocks.status = 'permission' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Needs permission · Unread') - expect(markup).toContain('lucide-message-circle-question-mark') - expect(markup).toContain('text-agent-question') - expect(markup).not.toContain('data-worktree-status-lane-unread=""') - expect(markup).not.toContain('data-worktree-unread-alert=""') - expect(markup).not.toContain('aria-label="Mark as read"') - expect(markup).not.toContain('lucide-bell') - }) - - it('keeps legacy unread working cards on the unread bell control', () => { - mocks.status = 'working' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Mark as read"') - expect(markup).toContain('Mark as read') - expect(markup).toContain('Working') - expect(markup).toContain('text-amber-500') - expect(markup).not.toContain('border-agent-working') - expect(markup).not.toContain('data-worktree-unread-alert=""') - }) - - it('shows status in the unread toggle affordance', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active · Mark as unread') - expect(markup).toContain('bg-emerald-500') - expect(markup.match(/data-tooltip-root/g)).toHaveLength(1) - }) - - it('keeps the quiet active dot ahead of PR status by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('uses PR status instead of the quiet active dot when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('size-[13px] translate-x-px') - expect(markup).toContain('text-rose-500/85') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).not.toContain('data-tooltip-root') - }) - - it('marks the review status as last known while its WSL host is off (E9)', () => { - const render = (reviewStale: boolean) => - renderToStaticMarkup( - - ) - const stale = render(true) - expect(stale).toContain('PR checks: Failed · Last known status') - expect(stale).toContain('shimmer') - // The cached badge is kept, not dropped. - expect(stale).toContain('text-rose-500/85') - expect(render(false)).not.toContain('Last known status') - }) - - it('uses the unified compact review glyph for GitLab MR status', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('MR checks: Pending') - expect(markup).toContain('viewBox="0 0 16 16"') - expect(markup).toContain('size-[13px] translate-x-px') - expect(markup).toContain('text-amber-500/85') - expect(markup).not.toContain('lucide-git-merge') - }) - - it('uses PR status instead of the quiet done dot when new card style is on', () => { - mocks.status = 'done' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('PR checks: Failed') - expect(markup).not.toContain('bg-emerald-500') - }) - - it('keeps sleeping distinct from PR status when new card style is on', () => { - // Sleeping retains done rows; test that state rather than inactive. - mocks.status = 'done' - mocks.sleeping = true - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Sleeping') - expect(markup).toContain('lucide-moon') - expect(markup).not.toContain('PR checks: Failed') - expect(markup).not.toContain('text-rose-500/85') - expect(markup).not.toContain('bg-neutral-500/40') - }) - - it('keeps sleeping moon distinct from the awake green dot when new card style is on', () => { - mocks.status = 'done' - mocks.sleeping = true - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Sleeping') - expect(markup).toContain('lucide-moon') - expect(markup).not.toContain('lucide-git-branch') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).not.toContain('bg-neutral-500/40') - }) - - it('distinguishes awake branch from sleeping moon when new card style is on', () => { - mocks.status = 'done' - const awakeMarkup = renderToStaticMarkup( - - ) - expect(awakeMarkup).toContain('Branch') - expect(awakeMarkup).toContain('lucide-git-branch') - expect(awakeMarkup).not.toContain('lucide-moon') - mocks.sleeping = true - const sleepingMarkup = renderToStaticMarkup( - - ) - expect(sleepingMarkup).toContain('Sleeping') - expect(sleepingMarkup).toContain('lucide-moon') - expect(sleepingMarkup).not.toContain('lucide-git-branch') - }) - - it('shows the green awake dot for quiet done workspaces when new card style is on', () => { - mocks.status = 'done' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Done') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('lucide-git-branch') - expect(markup).not.toContain('lucide-moon') - expect(markup).toContain('data-tooltip-root') - }) - - it('keeps working activity ahead of PR status in new card style', () => { - mocks.status = 'working' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Working') - expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('border-agent-working') - expect(markup).toContain('data-tooltip-root') - expect(markup).toContain('data-tooltip-content="">Working') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps permission activity ahead of PR status in new card style', () => { - mocks.status = 'permission' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Needs permission') - expect(markup).toContain('lucide-message-circle-question-mark') - expect(markup).toContain('text-agent-question') - expect(markup).toContain('data-tooltip-root') - expect(markup).toContain('data-tooltip-content="">Needs permission') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps unread ahead of PR status by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Mark as read"') - expect(markup).toContain('Mark as read') - expect(markup).not.toContain('Active · Mark as read') - expect(markup).not.toContain('PR checks: Failed') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).toContain('text-amber-500') - }) - - it('overlays an unread badge on PR status when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Mark as read"') - expect(markup).not.toContain('Mark as read') - expect(markup).toContain('PR checks: Failed · Unread') - expect(markup).toContain('data-worktree-status-lane-unread=""') - expect(markup).toContain('data-worktree-unread-alert=""') - expect(markup).not.toContain('group/unread') - expect(markup).not.toContain('cursor-pointer') - expect(markup).toContain('text-rose-500/85') - expect(markup).toContain('bg-amber-500') - expect(markup).not.toContain('lucide-bell') - expect(markup).not.toContain('text-amber-500') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).not.toContain('data-tooltip-root') - }) - - it('overlays an unread badge on the sleeping moon in new card style', () => { - mocks.status = 'done' - mocks.sleeping = true - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Sleeping · Unread') - expect(markup).toContain('data-worktree-status-lane-unread=""') - expect(markup).toContain('data-worktree-unread-alert=""') - expect(markup).not.toContain('Mark as read') - expect(markup).not.toContain('group/unread') - expect(markup).not.toContain('cursor-pointer') - expect(markup).toContain('lucide-moon') - expect(markup).toContain('bg-amber-500') - expect(markup).not.toContain('lucide-bell') - expect(markup).not.toContain('text-amber-500') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).not.toContain('data-tooltip-root') - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx b/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx index 43da760f40d..f06e1e44f92 100644 --- a/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.parity.test.tsx @@ -1,11 +1,11 @@ // @vitest-environment happy-dom import React, { type ReactNode } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' + import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../../../../../shared/global-settings-types' -import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts' + import type { Repo } from '../../../../../../shared/repo-types' import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' import type { Worktree } from '../../../../../../shared/worktree/types' @@ -135,20 +135,6 @@ function makeWorktree(overrides: Partial = {}): Worktree { } } -function review(overrides: Partial = {}): HostedReviewInfo { - return { - provider: 'github', - number: 456, - title: 'Card plugins', - state: 'open', - url: 'https://github.com/acme/orca/pull/456', - status: 'failure', - updatedAt: '2026-11-02T00:00:00.000Z', - mergeable: 'CONFLICTING', - ...overrides - } -} - type Case = { worktree?: Partial repo?: Partial | null @@ -159,107 +145,7 @@ type Case = { deleteModifier?: boolean } -const wt = makeWorktree() -const CASES: Record = { - plain: {}, - titleOnly: { cardProps: ['status'], worktree: { branch: '' } }, - noStatus: { cardProps: ['issue', 'pr', 'ports', 'inline-agents', 'host'] }, - noInlineAgents: { cardProps: ['status', 'unread', 'pr', 'host'] }, - branchMatchesName: { worktree: { displayName: 'feature/card-plugins' } }, - unread: { worktree: { isUnread: true } }, - main: { worktree: { isMainWorktree: true, branch: 'refs/heads/main' } }, - sparse: { worktree: { isSparse: true, sparseDirectories: ['src', 'docs'] } }, - mainSparse: { - worktree: { isMainWorktree: true, isSparse: true, sparseDirectories: ['src', 'docs'] } - }, - renameFailed: { worktree: { firstAgentMessageRenameError: 'agent said no' } }, - removalError: { worktree: { removalError: '/repo/worktrees/feature: busy' } }, - detached: { worktree: { branch: '', head: 'deadbeef' } }, - merging: { state: { gitConflictOperationByWorktree: { [wt.id]: 'merge' } } }, - rebasing: { state: { gitConflictOperationByWorktree: { [wt.id]: 'rebase' } } }, - remoteConflict: { - state: { - remoteBranchConflictByWorktreeId: { - [wt.id]: { remote: 'origin', branchName: 'feature/card-plugins' } - } - } - }, - prFailing: { - worktree: { linkedPR: 456 }, - state: { - hostedReviewCache: { - 'local::repo-1::feature/card-plugins': { data: review(), fetchedAt: 1 } - } - } - }, - details: { - worktree: { - linkedIssue: 12, - linkedLinearIssue: 'ORC-1', - comment: 'Waiting on review', - automationProvenance: { - kind: 'created-by-automation', - automationId: 'a1', - automationNameSnapshot: 'Nightly', - automationRunId: 'r1', - automationRunTitleSnapshot: 'Nightly run', - createdAt: 1, - executionTargetType: 'local', - executionTargetId: 'local', - projectId: 'repo-1' - } - } - }, - twoAttachments: { - worktree: { - linkedItems: [ - { type: 'pr', provider: 'github', number: 1, url: 'https://github.com/a/b/pull/1' }, - { type: 'pr', provider: 'github', number: 2, url: 'https://github.com/a/b/pull/2' } - ] - } - }, - cacheTimer: { cacheStartedAt: 1000 }, - deleteQuickAction: { deleteModifier: true }, - ssh: { repo: { connectionId: 'ssh-1' } }, - runtime: { repo: { executionHostId: 'runtime:env-1' } }, - folder: { repo: { kind: 'folder' }, worktree: { branch: '' } }, - folderWithGroups: { - repo: { kind: 'folder' }, - worktree: { branch: '' }, - state: { projectGroups: [{ id: 'g1' }] } - }, - noRepo: { repo: null }, - pinned: { props: { inPinnedSection: true } }, - pinnedWithProject: { props: { inPinnedSection: true, showProjectName: true } }, - hideRepoBadge: { props: { hideRepoBadge: true } }, - hostLabel: { props: { hostContextLabel: 'build-box' } }, - hostLabelWithProject: { props: { hostContextLabel: 'build-box', showProjectName: true } }, - lineage: { - props: { - lineageChildCount: 2, - lineageChildWorktreeIds: ['c1', 'c2'], - onLineageToggle: () => {} - } - }, - affiliate: { props: { affiliateListMode: true } }, - activeLinear: { props: { isActive: true }, worktree: { linkedLinearIssue: 'ORC-2' } }, - flushIndented: { props: { flushSurface: true, contentIndent: 24 } }, - indented: { props: { contentIndent: 16 } } -} - // Proof each case draws what it is named for, so a parity pass is not two empty cards. -const SHOWS: Partial> = { - sparse: 'sparse', - renameFailed: 'rename failed', - removalError: 'data-worktree-card-delete-failed', - merging: 'Merging', - remoteConflict: 'already exists', - cacheTimer: 'data-cache-timer', - deleteQuickAction: 'Delete workspace', - lineage: 'data-lineage-toggle', - details: 'Waiting on review', - activeLinear: 'Linear' -} const STYLES: Record> = { classic: { compactWorktreeCards: false }, @@ -347,47 +233,8 @@ function Harness({ props, variant }: { props: WorktreeCardProps; variant: 'legac } /** Attribute order and class token order do not change what the browser draws. */ -function canonical(html: string): string { - const host = document.createElement('div') - host.innerHTML = html - const walk = (node: Node): string => { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent ?? '' - } - if (!(node instanceof Element)) { - return '' - } - const attrs = [...node.attributes] - .map((attr) => - attr.name === 'class' - ? `class="${attr.value.split(/\s+/).filter(Boolean).sort().join(' ')}"` - : `${attr.name}="${attr.value}"` - ) - .sort() - return `<${node.localName} ${attrs.join(' ')}>${[...node.childNodes].map(walk).join('')}` - } - // React's generated ids depend on tree position, not on what is drawn. - const ids = new Map() - return [...host.childNodes] - .map(walk) - .join('') - .replace(/_R_[A-Za-z0-9]+_/g, (id) => { - if (!ids.has(id)) { - ids.set(id, `id${ids.size}`) - } - return ids.get(id) ?? id - }) -} // Why a window: full card markup is too long for a readable assertion diff. -function aroundFirstDifference(actual: string, expected: string): [string, string] { - let index = 0 - while (index < actual.length && actual[index] === expected[index]) { - index++ - } - const start = Math.max(0, index - 120) - return [actual.slice(start, index + 240), expected.slice(start, index + 240)] -} describe('default workspace card template parity with the original card', () => { beforeEach(() => { @@ -397,33 +244,6 @@ describe('default workspace card template parity with the original card', () => }) afterEach(cleanup) - for (const [styleName, style] of Object.entries(STYLES)) { - for (const [caseName, testCase] of Object.entries(CASES)) { - it(`${styleName}: ${caseName}`, () => { - mocks.cacheStartedAt = testCase.cacheStartedAt ?? null - mocks.deleteModifier = testCase.deleteModifier ?? false - mocks.state = stateFor(style, testCase) - const props: WorktreeCardProps = { - worktree: makeWorktree(testCase.worktree), - repo: testCase.repo === null ? undefined : { ...repo, ...testCase.repo }, - isActive: false, - ...testCase.props - } - const legacy = canonical(renderToStaticMarkup()) - const template = canonical( - renderToStaticMarkup() - ) - const shows = - caseName === 'sparse' && styleName === 'new' ? 'Sparse checkout' : SHOWS[caseName] - if (shows) { - expect(legacy).toContain(shows) - } - const [actualWindow, expectedWindow] = aroundFirstDifference(template, legacy) - expect(actualWindow).toBe(expectedWindow) - }) - } - } - it('suppresses a delayed details hover while a template identity icon is focused', () => { mocks.state = stateFor(STYLES.new, {}) const { container } = render( diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-expiry.test.tsx b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-expiry.test.tsx index 6ee3882269b..8325d8cf0f6 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-expiry.test.tsx +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-expiry.test.tsx @@ -80,44 +80,6 @@ afterEach(() => { }) describe('folder composer cache expiry requests', () => { - it('retains unrelated-expiry and reopen retries after an uncached failure', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - getPathStatus.mockRejectedValueOnce(new Error('Unavailable')) - const hook = renderHook(({ open }) => useFolderWorkspaceComposerPathStatus(group, open, null), { - initialProps: { open: true } - }) - await act(async () => {}) - await expireUnrelatedEntries() - expect(getPathStatus).toHaveBeenCalledTimes(2) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - hook.rerender({ open: false }) - hook.rerender({ open: true }) - await act(async () => {}) - expect(getPathStatus).toHaveBeenCalledTimes(3) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - }) - - it('does not refetch a fresh selected folder when other entries expire', async () => { - const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null)) - await act(async () => {}) - await expireUnrelatedEntries() - - expect(getPathStatus).toHaveBeenCalledExactlyOnceWith(request) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - }) - - it('retains expiry-triggered checks while the first result is unknown', async () => { - const gate = Promise.withResolvers<{ path: string; exists: boolean }>() - getPathStatus.mockReturnValue(gate.promise) - const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null)) - await expireUnrelatedEntries() - - expect(getPathStatus).toHaveBeenCalledTimes(101) - expect(hook.result.current.pathStatusBlocksCreate).toBe(true) - await act(async () => gate.resolve({ path: '/folder', exists: true })) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - }) - it('refreshes at the selected entry expiry and blocks while that refresh is pending', async () => { useAppStore.setState({ folderWorkspacePathStatuses: {} }) const gate = Promise.withResolvers<{ path: string; exists: boolean }>() @@ -136,12 +98,6 @@ describe('folder composer cache expiry requests', () => { expect(hook.result.current.pathStatusBlocksCreate).toBe(false) }) - it('does not check a folder without a selected group', async () => { - renderHook(() => useFolderWorkspaceComposerPathStatus(null, true, null)) - await expireUnrelatedEntries() - expect(getPathStatus).not.toHaveBeenCalled() - }) - it.each(['missing', 'not-directory', 'ambiguous-connection'] as const)( 'retains unrelated-expiry recovery after a cached %s refresh fails', async (reason) => { @@ -175,58 +131,6 @@ describe('folder composer cache expiry requests', () => { } ) - it('forces the initial check even when a matching fresh result is already cached', async () => { - const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, { - runtimeEnvironmentId: null - }) - expect(snapshot).not.toBeNull() - useAppStore.setState({ - folderWorkspacePathStatuses: { - 'local:project-group:selected': { - checkedAt: Date.now(), - requestSnapshot: snapshot ?? '', - status: { path: '/folder', exists: false, reason: 'missing' } - } - } - }) - const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null)) - await act(async () => {}) - expect(getPathStatus).toHaveBeenCalledExactlyOnceWith(request) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - }) - - it('retries a fresh timestamp whose request snapshot is no longer current', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - getPathStatus.mockRejectedValueOnce(new Error('Unavailable')) - useAppStore.setState((state) => ({ - folderWorkspacePathStatuses: { - ...state.folderWorkspacePathStatuses, - 'local:project-group:selected': { - checkedAt: Date.now(), - requestSnapshot: 'old-folder-snapshot', - status: { path: '/old-folder', exists: false, reason: 'missing' } - } - } - })) - const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null)) - await act(async () => {}) - expect(hook.result.current.pathStatusBlocksCreate).toBe(true) - await act(async () => vi.advanceTimersByTimeAsync(11)) - expect(getPathStatus).toHaveBeenCalledTimes(2) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - }) - - it('does not report a nonexistent pending check when cache is cleared after a skipped expiry', async () => { - const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null)) - await act(async () => {}) - await act(async () => vi.advanceTimersByTimeAsync(11)) - expect(getPathStatus).toHaveBeenCalledOnce() - await act(async () => useAppStore.setState({ folderWorkspacePathStatuses: {} })) - expect(getPathStatus).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - }) - it('keeps a real pending check pending when fresh cache is removed after unrelated expiry', async () => { const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, { runtimeEnvironmentId: null @@ -252,67 +156,4 @@ describe('folder composer cache expiry requests', () => { await act(async () => gate.resolve({ path: '/folder', exists: true })) expect(hook.result.current.pathStatusBlocksCreate).toBe(false) }) - - it('uses the selected runtime cache entry instead of a same-group local entry', async () => { - const fetchStatus = vi.fn().mockResolvedValue(null) - useAppStore.setState({ - projectGroups: [group, { ...group, executionHostId: 'runtime:env-1' }] - }) - const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, { - runtimeEnvironmentId: 'env-1' - }) - expect(snapshot).not.toBeNull() - const makeEntry = (checkedAt: number): FolderWorkspacePathStatusCacheEntry => ({ - checkedAt, - status: { path: '/folder', exists: true }, - requestSnapshot: snapshot ?? '' - }) - useAppStore.setState({ - fetchFolderWorkspacePathStatus: fetchStatus, - folderWorkspacePathStatuses: { - 'local:project-group:selected': makeEntry(90_010), - 'environment:env-1:project-group:selected': makeEntry(100_000) - } - }) - const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, 'env-1')) - await act(async () => {}) - await act(async () => vi.advanceTimersByTimeAsync(11)) - expect(fetchStatus).toHaveBeenCalledExactlyOnceWith(request, { - force: true, - runtimeEnvironmentId: 'env-1' - }) - expect(hook.result.current.pathStatusBlocksCreate).toBe(false) - await act(async () => vi.advanceTimersByTimeAsync(9990)) - expect(fetchStatus).toHaveBeenCalledTimes(2) - }) - - it('replaces the timer when the selected group changes', async () => { - const secondGroup: ProjectGroup = { ...group, id: 'second', parentPath: '/second' } - useAppStore.setState({ projectGroups: [group, secondGroup], folderWorkspacePathStatuses: {} }) - getPathStatus.mockImplementation(async (input) => ({ - path: - input.scope === 'project-group' && input.projectGroupId === 'second' - ? '/second' - : '/folder', - exists: true - })) - const hook = renderHook( - ({ selected }) => useFolderWorkspaceComposerPathStatus(selected, true, null), - { - initialProps: { selected: group } - } - ) - await act(async () => {}) - await act(async () => vi.advanceTimersByTimeAsync(5000)) - hook.rerender({ selected: secondGroup }) - await act(async () => {}) - getPathStatus.mockClear() - await act(async () => vi.advanceTimersByTimeAsync(5001)) - expect(getPathStatus).not.toHaveBeenCalled() - await act(async () => vi.advanceTimersByTimeAsync(5000)) - expect(getPathStatus).toHaveBeenCalledExactlyOnceWith({ - scope: 'project-group', - projectGroupId: 'second' - }) - }) }) diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.test.tsx b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.test.tsx deleted file mode 100644 index 65a4f403eb5..00000000000 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import { useAppStore } from '@/store/index' -import { getFolderWorkspaceStatusRequestSnapshot } from '@/store/folder-workspaces/folder-path-status' -import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' - -const initialState = useAppStore.getInitialState() - -const projectGroup: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: '/workspace/platform', - connectionId: null, - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} -const projectGroupRequestSnapshot = getFolderWorkspaceStatusRequestSnapshot( - { ...initialState, projectGroups: [projectGroup] }, - { scope: 'project-group', projectGroupId: projectGroup.id }, - { runtimeEnvironmentId: null } -) -if (projectGroupRequestSnapshot === null) { - throw new Error('Project group fixture must have a path status snapshot') -} - -let root: Root | null = null -let container: HTMLDivElement | null = null - -function HookProbe(): null { - const result = useFolderWorkspaceComposerPathStatus(projectGroup, true, null) - ;( - globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result } - ).__folderWorkspaceComposerPathStatusResult = result - return null -} - -describe('useFolderWorkspaceComposerPathStatus', () => { - afterEach(() => { - vi.useRealTimers() - act(() => { - root?.unmount() - }) - root = null - container?.remove() - container = null - delete (globalThis as { __folderWorkspaceComposerPathStatusResult?: unknown }) - .__folderWorkspaceComposerPathStatusResult - useAppStore.setState(initialState, true) - }) - - it('blocks creation while an expired path status refresh is pending', async () => { - vi.useFakeTimers() - vi.setSystemTime(20_000) - const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore - .getState() - .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) - const fetchFolderWorkspacePathStatus = vi.fn().mockResolvedValue(null) - useAppStore.setState({ - projectGroups: [projectGroup], - fetchFolderWorkspacePathStatus, - folderWorkspacePathStatuses: { - [cacheKey]: { - status: { - path: '/workspace/platform', - exists: false, - reason: 'missing' - }, - checkedAt: 0, - requestSnapshot: projectGroupRequestSnapshot - } - } - }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root?.render() - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(true) - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError - ).toContain('/workspace/platform') - - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(true) - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError - ).toContain('/workspace/platform') - expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { - force: true, - runtimeEnvironmentId: null - }) - }) - - it('unblocks creation when the first path status check settles without cache', async () => { - const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const fetchFolderWorkspacePathStatus = vi.fn().mockResolvedValue(null) - useAppStore.setState({ - projectGroups: [projectGroup], - fetchFolderWorkspacePathStatus, - folderWorkspacePathStatuses: {} - }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root?.render() - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(true) - - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(false) - expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { - force: true, - runtimeEnvironmentId: null - }) - }) - - it('does not block creation for an unavailable path status', () => { - vi.useFakeTimers() - vi.setSystemTime(20_000) - const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore - .getState() - .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) - useAppStore.setState({ - projectGroups: [projectGroup], - fetchFolderWorkspacePathStatus: vi.fn().mockResolvedValue(null), - folderWorkspacePathStatuses: { - [cacheKey]: { - status: { - path: '/workspace/platform', - exists: false, - reason: 'unavailable' - }, - checkedAt: 20_000, - requestSnapshot: projectGroupRequestSnapshot - } - } - }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root?.render() - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(false) - }) - - it('blocks while refreshing after a cached blocking path status expires', async () => { - vi.useFakeTimers() - vi.setSystemTime(20_000) - const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore - .getState() - .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) - useAppStore.setState({ - projectGroups: [projectGroup], - fetchFolderWorkspacePathStatus: vi.fn(), - folderWorkspacePathStatuses: { - [cacheKey]: { - status: { - path: '/workspace/platform', - exists: false, - reason: 'missing' - }, - checkedAt: 20_000, - requestSnapshot: projectGroupRequestSnapshot - } - } - }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root?.render() - }) - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(true) - - act(() => { - vi.advanceTimersByTime(10_001) - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(true) - - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - - expect( - ( - globalThis as { - __folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean } - } - ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate - ).toBe(true) - }) -}) diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts index 3a63452d4f2..4485cdc9acf 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts @@ -2,9 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' -import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' + import type * as NewWorkspaceModule from '@/lib/new-workspace' -import { getDefaultSettings } from '../../../../../../shared/constants' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn(), @@ -37,263 +36,6 @@ const linkedIssue = { repoId: 'repo-1' } -describe('submitFolderWorkspaceCreate', () => { - it.each(['claude', 'codex'] as const)( - 'retains untouched ordinary %s Settings in the complete initial folder startup', - async (agent) => { - const previous = useAppStore.getState().settings - const raw = agent === 'claude' ? '--verbose' : '--search' - const model = agent === 'claude' ? 'opus' : 'gpt-5.4' - const settings = { - ...getDefaultSettings('/test'), - agentDefaultSurface: 'terminal' as const, - agentPermissionDefault: 'own' as const, - agentDefaultArgs: { [agent]: raw }, - agentLaunchChoices: { [agent]: { model, options: { effort: 'low' } } } - } - useAppStore.setState({ settings }) - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) - const onOpenChange = vi.fn() - try { - expect( - await submitFolderWorkspaceCreate({ - projectGroup: makeProjectGroup(), - name: 'hi', - lastAutoName: '', - linkedWorkItem: null, - note: '', - quickAgent: agent, - autoRenameBranchFromWork: false, - settings, - agentCmdOverrides: {}, - launchSource: 'new_workspace_composer', - createFolderWorkspace, - onOpenChange - }) - ).toBe(true) - const command = - agent === 'claude' - ? "claude '--verbose' '--model' 'opus' '--effort' 'low'" - : `codex '-c' 'disable_paste_burst=true' '--search' '-m' 'gpt-5.4' '-c' 'model_reasoning_effort=low'` - expect(createFolderWorkspace.mock.calls).toEqual([ - [ - { - projectGroupId: 'group-1', - name: 'hi', - connectionId: null, - linkedTask: null, - createdWithAgent: agent - } - ] - ]) - expect(mocks.activateAndRevealFolderWorkspace.mock.calls).toEqual([ - [ - 'folder-workspace-1', - { - agent, - runtimeEnvironmentId: null, - startup: { - command, - env: {}, - launchConfig: { agentCommand: `${agent} '${raw}'`, agentArgs: raw, agentEnv: {} }, - launchToken: expect.stringMatching(/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/), - launchAgent: agent, - telemetry: { - agent_kind: agent === 'claude' ? 'claude-code' : 'codex', - launch_source: 'new_workspace_composer', - request_kind: 'new' - } - } - } - ] - ]) - expect(onOpenChange.mock.calls).toEqual([[false]]) - expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() - } finally { - useAppStore.setState({ settings: previous }) - } - } - ) - - beforeEach(() => { - mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' }) - }) - - afterEach(() => { - mocks.activateAndRevealFolderWorkspace.mockReset() - mocks.ensureAgentStartupInTerminal.mockReset() - vi.restoreAllMocks() - }) - - it('keeps linked Codex context out of submitted startup and pastes it as a draft', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) - const linkedWorkItem = { - provider: 'github' as const, - type: 'pr' as const, - number: 91, - title: 'Restore linked quick-create', - url: 'https://github.com/stablyai/orca/pull/91', - repoId: 'repo-1' - } - - await submitFolderWorkspaceCreate({ - projectGroup: makeProjectGroup(), - name: '', - lastAutoName: '', - linkedWorkItem, - note: 'Review this before starting', - quickAgent: 'codex', - autoRenameBranchFromWork: true, - agentCmdOverrides: {}, - launchSource: 'new_workspace_composer', - createFolderWorkspace, - onOpenChange: vi.fn() - }) - - expect(createFolderWorkspace).toHaveBeenCalledWith({ - projectGroupId: 'group-1', - name: 'Restore linked quick-create', - connectionId: null, - linkedTask: linkedWorkItem, - createdWithAgent: 'codex' - }) - const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup - expect(startup?.command).toBe("codex '-c' 'disable_paste_burst=true'") - expect(startup?.command).not.toContain(linkedWorkItem.url) - expect(startup?.command).not.toContain('Review this before starting') - expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith({ - worktreeId: folderWorkspaceKey('folder-workspace-1'), - primaryTabId: 'tab-1', - startup: expect.objectContaining({ - agent: 'codex', - launchCommand: "codex '-c' 'disable_paste_burst=true'", - followupPrompt: null, - draftPrompt: `Review this before starting\n\n${linkedWorkItem.url}` - }) - }) - }) - - it('pastes the linked draft for remote Codex folder workspaces', async () => { - const createFolderWorkspace = vi.fn(async () => - makeFolderWorkspace({ - connectionId: 'ssh-1', - folderPath: '/home/alice/platform/Trust remote folder draft' - }) - ) - const linkedWorkItem = { - provider: 'github' as const, - type: 'pr' as const, - number: 92, - title: 'Trust remote folder draft', - url: 'https://github.com/stablyai/orca/pull/92', - repoId: 'repo-1' - } - const projectGroup = { - ...makeProjectGroup(), - connectionId: 'ssh-1', - parentPath: '/home/alice/platform' - } - useAppStore.setState({ - sshConnectionStates: new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - remotePlatform: 'linux' - } - ] - ]) - }) - - await submitFolderWorkspaceCreate({ - projectGroup, - name: '', - lastAutoName: '', - linkedWorkItem, - note: '', - quickAgent: 'codex', - autoRenameBranchFromWork: false, - agentCmdOverrides: {}, - isRemote: true, - createFolderWorkspace, - onOpenChange: vi.fn() - }) - - expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: folderWorkspaceKey('folder-workspace-1'), - startup: expect.objectContaining({ - agent: 'codex', - draftPrompt: linkedWorkItem.url - }) - }) - ) - }) - - it('delivers non-linked follow-up prompts for agents that need stdin after launch', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) - - await submitFolderWorkspaceCreate({ - projectGroup: makeProjectGroup(), - name: 'Aider followup', - lastAutoName: '', - linkedWorkItem: null, - note: 'Fix the failing folder prompt flow', - quickAgent: 'aider', - autoRenameBranchFromWork: false, - agentCmdOverrides: {}, - createFolderWorkspace, - onOpenChange: vi.fn() - }) - - const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup - expect(startup?.command).toBe('aider') - expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith({ - worktreeId: folderWorkspaceKey('folder-workspace-1'), - primaryTabId: 'tab-1', - startup: expect.objectContaining({ - agent: 'aider', - launchCommand: 'aider', - followupPrompt: 'Fix the failing folder prompt flow' - }) - }) - }) - - it('uses native draft launch for linked agents with prefill support', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) - const linkedWorkItem = { - provider: 'gitlab' as const, - type: 'mr' as const, - number: 17, - title: 'Review folder workspace draft', - url: 'https://gitlab.example.com/group/project/-/merge_requests/17', - repoId: 'repo-1' - } - - await submitFolderWorkspaceCreate({ - projectGroup: makeProjectGroup(), - name: '', - lastAutoName: '', - linkedWorkItem, - note: 'Check the migration path', - quickAgent: 'claude', - autoRenameBranchFromWork: true, - agentCmdOverrides: {}, - createFolderWorkspace, - onOpenChange: vi.fn() - }) - - const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup - expect(startup?.command).toContain('claude --prefill') - expect(startup?.command).toContain('Check the migration path') - expect(startup?.command).toContain(linkedWorkItem.url) - expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() - }) -}) - describe('submitFolderWorkspaceCreate native-chat launch draft', () => { function seededDraftFor(tabId: string): { text: string } | undefined { return useAppStore.getState().nativeChatLaunchDraftByTabId[tabId] @@ -349,35 +91,3 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => { expect(seededDraftFor('tab-1')?.text).toBe(ISSUE_URL) }) }) - -it.each(['claude', 'codex'] as const)( - 'rejects invalid requested %s startup before folder creation and preserves valid options', - async (agent) => { - const input = { - projectGroup: makeProjectGroup(), - name: 'configured', - lastAutoName: '', - linkedWorkItem: null, - note: 'fix', - quickAgent: agent, - launchChoice: { agent, route: 'terminal-tui' as const }, - autoRenameBranchFromWork: false, - agentCmdOverrides: {}, - agentArgs: '--add-dir "docs', - sessionOptions: { model: agent === 'claude' ? 'sonnet' : 'gpt-5.4' }, - createFolderWorkspace: vi.fn(async () => makeFolderWorkspace()), - onOpenChange: vi.fn() - } - for (const linkedWorkItem of [null, linkedIssue]) { - await expect(submitFolderWorkspaceCreate({ ...input, linkedWorkItem })).rejects.toThrow( - 'Check Arguments in this setup' - ) - expect(input.createFolderWorkspace).not.toHaveBeenCalled() - expect(input.onOpenChange).not.toHaveBeenCalled() - } - expect(await submitFolderWorkspaceCreate({ ...input, agentArgs: '--add-dir docs' })).toBe(true) - const startup = mocks.activateAndRevealFolderWorkspace.mock.calls.at(-1)?.[1]?.startup - expect(startup.command).toMatch(/--add-dir.*docs/) - expect(startup.command).toContain(input.sessionOptions.model) - } -) diff --git a/src/renderer/src/components/sidebar/workspace-visibility/dialog/WorktreeVisibilityDialog.test.tsx b/src/renderer/src/components/sidebar/workspace-visibility/dialog/WorktreeVisibilityDialog.test.tsx index 7f1d3ca77f6..ebb72e01ba5 100644 --- a/src/renderer/src/components/sidebar/workspace-visibility/dialog/WorktreeVisibilityDialog.test.tsx +++ b/src/renderer/src/components/sidebar/workspace-visibility/dialog/WorktreeVisibilityDialog.test.tsx @@ -209,263 +209,13 @@ function sourceSegment(label: string, visibility: 'show' | 'hide'): HTMLButtonEl const sourceSwitch = (label = 'Claude Code'): HTMLButtonElement => sourceSegment(label, 'show') -function sourceRow(label: string): HTMLElement { - const row = sourceSwitch(label).closest('[data-source-row]') - if (!row) { - throw new Error(`No ${label} source row`) - } - return row -} - -function inheritEverythingFromGlobal(): void { - mocks.state.repos = [makeRepo({ externalWorktreeVisibility: undefined })] - mocks.state.settings = { - worktreeVisibilityDefaults: { - external: 'hide', - sourcePreferences: { builtIn: { claude: 'show' } } - } - } -} - async function click(element: HTMLElement): Promise { await act(async () => { element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) }) } -async function setInputValue(input: HTMLInputElement, value: string): Promise { - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) - input.dispatchEvent(new Event('input', { bubbles: true })) - input.dispatchEvent(new Event('change', { bubbles: true })) - }) -} - describe('WorktreeVisibilityDialog', () => { - it('links directly to the global visibility defaults', async () => { - await renderDialog() - - const manageGlobalSettings = buttonWithText('Manage in Global Settings') - expect(manageGlobalSettings.querySelector('.lucide-settings')).not.toBeNull() - await click(manageGlobalSettings) - - expect(mocks.state.closeModal).toHaveBeenCalledOnce() - expect(mocks.state.openSettingsTarget).toHaveBeenCalledWith({ - pane: 'general', - repoId: null, - sectionId: 'general-global-worktree-visibility' - }) - expect(mocks.state.openSettingsPage).toHaveBeenCalledOnce() - }) - - it('lists a hidden agent worktree with a repo-relative path', async () => { - await renderDialog() - - expect(document.body.textContent).toContain('Hidden worktrees (1)') - expect(document.body.textContent).toContain('Show one without enabling its source.') - expect(document.body.textContent).toContain('scratch-1') - expect(document.body.textContent).toContain('.claude/worktrees/scratch-1') - expect(document.body.textContent).not.toContain('/repo/.claude') - expect( - document.querySelector('button[aria-label="Which worktrees are hidden by default?"]') - ).not.toBeNull() - }) - - it('bounds rendered rows for repositories with many hidden worktrees', async () => { - const worktrees = Array.from({ length: 500 }, (_, index) => - makeWorktree({ - id: `hidden-${index}`, - path: `/repo/.claude/worktrees/scratch-${index}`, - displayName: `scratch-${index}` - }) - ) - mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected(worktrees) } - - await renderDialog() - - expect(document.body.textContent).toContain('Hidden worktrees (500)') - expect(document.querySelectorAll('ul > li').length).toBeLessThan(20) - const search = document.querySelector( - 'input[aria-label="Search hidden worktrees"]' - ) - expect(search).not.toBeNull() - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( - search, - 'scratch-499' - ) - search!.dispatchEvent(new Event('input', { bubbles: true })) - search!.dispatchEvent(new Event('change', { bubbles: true })) - }) - expect(document.body.textContent).toContain('scratch-499') - expect(document.body.textContent).not.toContain('scratch-0') - }) - - it('gives each hidden worktree action a distinct accessible name', async () => { - mocks.state.detectedWorktreesByRepo = { - 'repo-1': makeDetected([ - makeWorktree({ displayName: 'scratch-a', path: '/repo/.claude/worktrees/scratch-a' }), - makeWorktree({ displayName: 'scratch-b', path: '/repo/.claude/worktrees/scratch-b' }) - ]) - } - - await renderDialog() - - expect( - document.querySelector('button[aria-label="Show scratch-a at .claude/worktrees/scratch-a"]') - ).not.toBeNull() - expect( - document.querySelector('button[aria-label="Show scratch-b at .claude/worktrees/scratch-b"]') - ).not.toBeNull() - }) - - it('recovers a hidden worktree per path through the existing import exception', async () => { - await renderDialog() - - await click(buttonWithText('Show')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - importedExternalWorktreePaths: [SCRATCH_PATH], - externalWorktreeInboxBaselinePaths: [SCRATCH_PATH] - }) - expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', { - requireAuthoritative: true - }) - expect(mocks.state.closeModal).not.toHaveBeenCalled() - }) - - it('omits the hidden list when nothing is recoverable', async () => { - mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected([]) } - await renderDialog() - - expect(document.body.textContent).toContain('Hidden worktrees (0)') - expect(document.body.textContent).toContain('No non-Orca worktrees found') - }) - - it('says it is checking instead of claiming nothing is hidden on a fallback snapshot', async () => { - mocks.state.detectedWorktreesByRepo = { - 'repo-1': makeDetected([makeWorktree()], { authoritative: false, source: 'session-fallback' }) - } - mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {})) - await renderDialog() - - expect(document.body.textContent).toContain('Checking…') - expect(document.body.textContent).not.toContain('Hidden worktrees') - expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', { - requireAuthoritative: true - }) - }) - - it('offers a retry instead of a dead end when the list cannot be read', async () => { - mocks.state.detectedWorktreesByRepo = { - 'repo-1': makeDetected([makeWorktree()], { authoritative: false, source: 'session-fallback' }) - } - mocks.state.fetchWorktrees.mockResolvedValue(false) - await renderDialog() - - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - "Could not list this repo's worktrees." - ) - - mocks.state.fetchWorktrees.mockImplementation(async () => { - mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() } - return true - }) - await click(buttonWithText('Try again')) - - expect(document.querySelector('[role="alert"]')).toBeNull() - expect(document.body.textContent).toContain('Hidden worktrees') - }) - - it('keeps rows visible but not actionable until the open-time scan settles', async () => { - // Mid-scan writes must not reuse a pre-write confirmation fetch and falsely report successful recovery. - mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {})) - await renderDialog() - - expect(document.body.textContent).toContain('scratch-1') - expect(buttonWithText('Show').disabled).toBe(true) - expect(document.body.textContent).toContain('Checking…') - }) - - it('locks the repo-wide toggle until the open-time scan settles', async () => { - // Post-write refresh must not coalesce onto scans started before the visibility write. - mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {})) - await renderDialog() - - expect(sourceSwitch().disabled).toBe(true) - }) - - it('reports a failed refresh even while an older trusted snapshot is on screen', async () => { - // Warm snapshots need failure indicators when the host has since become unreachable. - mocks.state.fetchWorktrees.mockResolvedValue(false) - await renderDialog() - - expect(document.body.textContent).toContain('scratch-1') - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - "Could not list this repo's worktrees." - ) - }) - - it('locks retry while a row import is in flight, so it cannot race the write', async () => { - // Retry must not start a pre-import scan that absorbs the import’s confirmation refresh. - mocks.state.fetchWorktrees.mockResolvedValue(false) - mocks.state.updateRepo.mockImplementation(() => new Promise(() => {})) - await renderDialog() - - await click(buttonWithText('Show')) - - expect(buttonWithText('Try again').disabled).toBe(true) - }) - - it('locks the repo-wide toggle while a row import is in flight', async () => { - mocks.state.fetchWorktrees.mockResolvedValue(false) - mocks.state.updateRepo.mockImplementation(() => new Promise(() => {})) - await renderDialog() - - await click(buttonWithText('Show')) - - expect(sourceSwitch().disabled).toBe(true) - }) - - it('locks row actions and retry while the repo-wide toggle is in flight', async () => { - mocks.state.fetchWorktrees.mockResolvedValueOnce(false) - mocks.state.updateRepo.mockImplementation(() => new Promise(() => {})) - await renderDialog() - - await click(sourceSwitch()) - - expect(buttonWithText('Show').disabled).toBe(true) - expect(buttonWithText('Try again').disabled).toBe(true) - }) - - it('clears a stale failure once a row import refreshes the list successfully', async () => { - mocks.state.fetchWorktrees.mockResolvedValueOnce(false) - await renderDialog() - expect(document.querySelector('[role="alert"]')).not.toBeNull() - - mocks.state.fetchWorktrees.mockResolvedValue(true) - await click(buttonWithText('Show')) - - expect(document.querySelector('[role="alert"]')).toBeNull() - }) - - it('does not leak an old repo action failure into a newly opened repo', async () => { - const update = deferred() - mocks.state.updateRepo.mockReturnValueOnce(update.promise) - await renderDialog() - await click(buttonWithText('Show')) - - mocks.state.modalData = { repoId: 'repo-2' } - mocks.state.repos = [makeRepo({ id: 'repo-2', path: '/repo-2', displayName: 'other' })] - mocks.state.detectedWorktreesByRepo = { 'repo-2': makeDetected([], { repoId: 'repo-2' }) } - await renderDialog() - - update.resolve(false) - await act(async () => update.promise) - - expect(document.querySelector('[role="alert"]')).toBeNull() - }) - it('keeps a remounted repo locked until its earlier row action settles', async () => { const update = deferred() mocks.state.updateRepo.mockReturnValueOnce(update.promise) @@ -561,19 +311,6 @@ describe('WorktreeVisibilityDialog', () => { }) }) - it('reports a failed persistent visibility update without starting a refresh', async () => { - mocks.state.updateRepo.mockResolvedValue(false) - await renderDialog() - mocks.state.fetchWorktrees.mockClear() - - await click(sourceSwitch()) - - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - 'Could not update worktree visibility. Try again.' - ) - expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() - }) - it('does not report success when an older host strips additive source settings (STA-4092)', async () => { mocks.state.updateRepo.mockResolvedValue(true) await renderDialog() @@ -588,277 +325,6 @@ describe('WorktreeVisibilityDialog', () => { expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() }) - it('reports a failed authoritative refresh after updating persistent visibility', async () => { - await renderDialog() - mocks.state.fetchWorktrees.mockResolvedValue(false) - - await click(sourceSwitch()) - - expect(mocks.state.fetchWorktrees).toHaveBeenLastCalledWith('repo-1', { - requireAuthoritative: true - }) - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - "Could not list this repo's worktrees." - ) - }) - - it('toggles built-in sources independently', async () => { - await renderDialog() - - expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('false') - expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('false') - - await click(sourceSwitch('Claude Code')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - worktreeVisibilitySourcePreferences: { - builtIn: { claude: 'show' } - } - }) - expect(mocks.state.closeModal).not.toHaveBeenCalled() - }) - - it('shows when source visibility is inherited from the global default', async () => { - inheritEverythingFromGlobal() - - await renderDialog() - - expect(sourceRow('Claude Code').textContent).not.toContain('Overriding global setting') - expect(sourceRow('GSD').textContent).not.toContain('Overriding global setting') - expect(sourceRow('Other locations').textContent).not.toContain('Overriding global setting') - expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true') - expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('false') - }) - - it('lists what each inheritable source is set to in global settings', async () => { - inheritEverythingFromGlobal() - - await renderDialog() - - const intro = [...document.querySelectorAll('p')].find( - (candidate) => - candidate.textContent === 'These sources have a global setting you can override here:' - ) - expect(intro).not.toBeUndefined() - expect( - [...(intro?.nextElementSibling?.querySelectorAll('li') ?? [])].map((item) => item.textContent) - ).toEqual(['Claude CodeShow', 'GSDHide', 'Other locationsHide']) - expect(buttonWithText('Manage in Global Settings')).not.toBeNull() - }) - - it('names the global value an override is ignoring and reverts on the same control', async () => { - mocks.state.repos = [ - makeRepo({ - worktreeVisibilitySourcePreferences: { - builtIn: { claude: 'show', gsd: 'show' } - } - }) - ] - mocks.state.settings = { - worktreeVisibilityDefaults: { - external: 'hide', - sourcePreferences: { builtIn: { claude: 'hide', gsd: 'hide' } } - } - } - await renderDialog() - - expect(sourceRow('Claude Code').textContent).toContain('Overriding global setting: Hide') - await click(sourceSegment('Claude Code', 'hide')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - agentWorktreeVisibility: null, - worktreeVisibilitySourcePreferences: { builtIn: { gsd: 'show' } } - }) - }) - - it('materializes the remaining legacy source override when one source reverts to global', async () => { - mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })] - mocks.state.settings = { worktreeVisibilityDefaults: { external: 'hide' } } - await renderDialog() - - await click(sourceSegment('Claude Code', 'hide')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - agentWorktreeVisibility: null, - worktreeVisibilitySourcePreferences: { builtIn: { gsd: 'show' } } - }) - }) - - it('migrates legacy Always show to both built-in source rows', async () => { - mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })] - // Global must also show so hiding creates an override rather than reverting to inheritance. - mocks.state.settings = { - worktreeVisibilityDefaults: { - external: 'hide', - sourcePreferences: { builtIn: { claude: 'show', gsd: 'show' } } - } - } - await renderDialog() - - expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true') - expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('true') - - await click(sourceSegment('Claude Code', 'hide')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - worktreeVisibilitySourcePreferences: { - builtIn: { claude: 'hide', gsd: 'show' } - } - }) - }) - - it('keeps ordinary non-Orca visibility on its own source row', async () => { - mocks.state.repos = [makeRepo({ externalWorktreeVisibility: 'show' })] - // Global must also show so hiding creates an override rather than reverting to inheritance. - mocks.state.settings = { worktreeVisibilityDefaults: { external: 'show' } } - await renderDialog() - - expect(sourceSwitch('Other locations').getAttribute('aria-checked')).toBe('true') - await click(sourceSegment('Other locations', 'hide')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - externalWorktreeVisibility: 'hide' - }) - }) - - it('adds custom locations disabled by default', async () => { - await renderDialog() - const input = document.querySelector('#custom-worktree-root') - expect(input).not.toBeNull() - expect( - document.querySelector('section[aria-labelledby="worktree-sources-heading"]')?.contains(input) - ).toBe(true) - expect(input?.closest('form')?.getAttribute('aria-label')).toBe('Add location') - expect(input?.closest('form')?.textContent).not.toContain('Add location') - await setInputValue(input!, '/srv/team-worktrees') - await click(buttonWithText('Add')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith( - 'repo-1', - expect.objectContaining({ - customWorktreeVisibilitySources: [ - expect.objectContaining({ rootPath: '/srv/team-worktrees' }) - ], - worktreeVisibilitySourcePreferences: expect.objectContaining({ - custom: expect.objectContaining({}) - }) - }) - ) - const update = mocks.state.updateRepo.mock.calls.at(-1)?.[1] as { - customWorktreeVisibilitySources: { id: string }[] - worktreeVisibilitySourcePreferences: { custom: Record } - } - expect( - update.worktreeVisibilitySourcePreferences.custom[ - update.customWorktreeVisibilitySources[0]!.id - ] - ).toBe('hide') - }) - - it('keeps source matching stable while typing in the inline form', async () => { - mocks.state.repos = [ - makeRepo({ - customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }] - }) - ] - await renderDialog() - const normalize = vi.spyOn(String.prototype, 'normalize') - - await setInputValue( - document.querySelector('#custom-worktree-root')!, - '/srv/x' - ) - - expect(normalize).not.toHaveBeenCalled() - normalize.mockRestore() - }) - - it('distinguishes invalid, duplicate, limit, and save failures when adding a source', async () => { - const existing = Array.from({ length: 32 }, (_, index) => ({ - id: `source-${index}`, - rootPath: `/srv/source-${index}` - })) - mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing })] - await renderDialog() - const input = document.querySelector('#custom-worktree-root')! - await setInputValue(input, '/srv/new') - await click(buttonWithText('Add')) - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - 'Remove a custom location before adding another.' - ) - expect(mocks.state.updateRepo).not.toHaveBeenCalled() - - mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing.slice(0, 1) })] - await act(async () => root.render(null)) - await renderDialog() - const duplicateInput = document.querySelector('#custom-worktree-root')! - await setInputValue(duplicateInput, '/srv/source-0') - await click(buttonWithText('Add')) - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - 'This location is already listed.' - ) - - await setInputValue(duplicateInput, 'relative/path') - await click(buttonWithText('Add')) - expect(document.querySelector('[role="alert"]')?.textContent).toContain( - 'Enter an absolute path for this host.' - ) - - mocks.state.updateRepo.mockResolvedValue(false) - await setInputValue(duplicateInput, '/srv/valid') - await click(buttonWithText('Add')) - const alerts = [...document.querySelectorAll('[role="alert"]')] - expect(alerts).toHaveLength(1) - expect(alerts[0]?.textContent).toContain('Could not update worktree visibility. Try again.') - expect(alerts[0]?.textContent).not.toContain('Enter an absolute path') - }) - - it('reports only unsupported-host copy when an older host strips an added source', async () => { - mocks.state.updateRepo.mockResolvedValue(true) - await renderDialog() - mocks.state.fetchWorktrees.mockClear() - const input = document.querySelector('#custom-worktree-root')! - - await setInputValue(input, '/srv/valid') - await click(buttonWithText('Add')) - - const alerts = [...document.querySelectorAll('[role="alert"]')] - expect(alerts).toHaveLength(1) - expect(alerts[0]?.textContent).toContain( - "This host doesn't support source-specific worktree visibility." - ) - expect(alerts[0]?.textContent).not.toContain('Enter an absolute path') - expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() - }) - - it('removes custom locations without changing other source preferences', async () => { - mocks.state.repos = [ - makeRepo({ - customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], - worktreeVisibilitySourcePreferences: { custom: { team: 'hide' } } - }) - ] - await renderDialog() - - expect(sourceSwitch('/srv/team-worktrees').getAttribute('aria-checked')).toBe('false') - const remove = document.querySelector( - 'button[aria-label="Remove /srv/team-worktrees"]' - ) - expect(remove).not.toBeNull() - await click(remove!) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith( - 'repo-1', - expect.objectContaining({ - customWorktreeVisibilitySources: [], - worktreeVisibilitySourcePreferences: expect.not.objectContaining({ - custom: expect.objectContaining({ team: expect.anything() }) - }) - }) - ) - expect(mocks.state.closeModal).not.toHaveBeenCalled() - }) - it('rejects removal success when an older host leaves the custom preference behind', async () => { const repo = makeRepo({ customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], @@ -879,70 +345,4 @@ describe('WorktreeVisibilityDialog', () => { ) expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() }) - - it('uses full paths to distinguish custom source controls with the same basename', async () => { - mocks.state.repos = [ - makeRepo({ - customWorktreeVisibilitySources: [ - { id: 'alpha', rootPath: '/srv/alpha/team' }, - { id: 'beta', rootPath: '/srv/beta/team' } - ] - }) - ] - - await renderDialog() - - expect(sourceSwitch('/srv/alpha/team')).not.toBeNull() - expect(sourceSwitch('/srv/beta/team')).not.toBeNull() - expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull() - expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull() - }) - - it('shows inherited global locations without repository removal controls', async () => { - mocks.state.settings = { - worktreeVisibilityDefaults: { - external: 'hide', - customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }], - sourcePreferences: { custom: { 'global-team': 'show' } } - } - } - - await renderDialog() - - expect(sourceSwitch('/srv/global-team').getAttribute('aria-checked')).toBe('true') - expect(document.querySelector('button[aria-label="Remove /srv/global-team"]')).toBeNull() - await click(sourceSegment('/srv/global-team', 'hide')) - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } } - }) - }) - - it('resets global custom and Other locations without changing their global defaults', async () => { - mocks.state.repos = [ - makeRepo({ - externalWorktreeVisibility: 'show', - worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } } - }) - ] - mocks.state.settings = { - worktreeVisibilityDefaults: { - external: 'hide', - customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }], - sourcePreferences: { custom: { 'global-team': 'show' } } - } - } - await renderDialog() - - expect(sourceRow('/srv/global-team').textContent).toContain('Overriding global setting: Show') - expect(sourceRow('Other locations').textContent).toContain('Overriding global setting: Hide') - await click(sourceSegment('/srv/global-team', 'show')) - await click(sourceSegment('Other locations', 'hide')) - - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - worktreeVisibilitySourcePreferences: {} - }) - expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { - externalWorktreeVisibility: null - }) - }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.test.ts deleted file mode 100644 index 68243713843..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getExecutionHostLabel } from '../../../../../../shared/execution-host' -import { - buildSidebarHostOptions, - buildSidebarHostScopeOptions, - getSidebarHostVisibilityLabel, - getSidebarHostHealthLabel, - shouldShowHostScopeControls, - type SidebarHostOption -} from './sidebar-host-options' -import { toggleVisibleHostRow } from '../../../../../../shared/workspace-view/workspace-host-visibility' -import { filterFolderWorkspacesForVisibleHosts } from '../../../../../../shared/sidebar-list/host-filtering' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { SshConnectionState } from '../../../../../../shared/ssh-types' - -const LOCAL_HOST_LABEL = getExecutionHostLabel('local') - -describe('sidebar host options', () => { - it('hides host controls for local-only workspaces', () => { - const hosts = buildSidebarHostOptions({ - repos: [{ connectionId: null }], - sshTargetLabels: new Map(), - settings: { activeRuntimeEnvironmentId: null } - }) - - expect(hosts).toEqual([ - { - id: 'local', - label: LOCAL_HOST_LABEL, - detail: 'This computer', - kind: 'local', - health: 'local', - presence: 'local' - } - ]) - expect(shouldShowHostScopeControls(hosts)).toBe(false) - }) - - it('includes SSH hosts from labels and repos', () => { - const hosts = buildSidebarHostOptions({ - repos: [{ connectionId: 'ssh-from-repo' }], - sshTargetLabels: new Map([['ssh-saved', 'Saved SSH']]), - settings: { activeRuntimeEnvironmentId: null } - }) - - expect(hosts.map((host) => host.id)).toEqual(['local', 'ssh:ssh-saved', 'ssh:ssh-from-repo']) - expect(hosts.map((host) => host.health)).toEqual(['local', 'disconnected', 'disconnected']) - expect(hosts.find((host) => host.id === 'ssh:ssh-saved')?.presence).toBe('configured') - expect(hosts.find((host) => host.id === 'ssh:ssh-from-repo')?.presence).toBe('project') - expect(shouldShowHostScopeControls(hosts)).toBe(true) - }) - - it('includes SSH health in options', () => { - const hosts = buildSidebarHostOptions({ - repos: [{ connectionId: 'ssh-1' }], - sshTargetLabels: new Map([['ssh-1', 'Builder']]), - sshConnectionStates: new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - } - ] - ]), - settings: { activeRuntimeEnvironmentId: null } - }) - - expect(hosts.find((host) => host.id === 'ssh:ssh-1')).toMatchObject({ - label: 'Builder', - health: 'available' - }) - }) - - it('says why an SSH host failed, as Settings does (#29648)', () => { - const failed = (state: Pick & Partial) => - buildSidebarHostOptions({ - repos: [], - sshTargetLabels: new Map([['ssh-1', 'Builder']]), - sshConnectionStates: new Map([ - ['ssh-1', { targetId: 'ssh-1', error: null, reconnectAttempt: 0, ...state }] - ]), - settings: { activeRuntimeEnvironmentId: null } - }).find((host) => host.id === 'ssh:ssh-1') - - expect( - failed({ status: 'auth-failed', error: 'All configured authentication methods failed' }) - ).toMatchObject({ - health: 'error', - detail: 'SSH', - failureReason: 'All configured authentication methods failed' - }) - expect( - failed({ - status: 'error', - error: 'raw', - managedServer: { - kind: 'relay', - reason: 'refused', - detail: 'This SSH host cannot move yet: automations (1).' - } - })?.failureReason - ).toContain('automations (1)') - expect(failed({ status: 'connected' })).not.toHaveProperty('failureReason') - }) - - it('includes the focused runtime compatibility host', () => { - const hosts = buildSidebarHostOptions({ - repos: [], - sshTargetLabels: new Map(), - settings: { activeRuntimeEnvironmentId: 'runtime-1' } - }) - - expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1']) - // A first probe still in progress is not evidence of disconnection. - expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({ - detail: 'Orca server', - health: 'connecting' - }) - }) - - it('uses saved runtime environment names for runtime host labels', () => { - const hosts = buildSidebarHostOptions({ - repos: [], - sshTargetLabels: new Map(), - settings: { activeRuntimeEnvironmentId: '03ef704c-b180-4b10-998d-e28fbd5de9a3' }, - runtimeEnvironments: [ - { - id: '03ef704c-b180-4b10-998d-e28fbd5de9a3', - name: 'dev box' - } - ] - }) - - expect(hosts.find((host) => host.id.startsWith('runtime:'))).toMatchObject({ - label: 'dev box', - detail: 'Orca server' - }) - }) - - it('marks a runtime host blocked when its live status fails compat', () => { - const hosts = buildSidebarHostOptions({ - repos: [], - sshTargetLabels: new Map(), - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, - // Protocol zero must produce a blocked server-too-old verdict from the live status map. - runtimeStatusByEnvironmentId: new Map([ - [ - 'runtime-1', - { - checkedAt: 0, - status: { - runtimeId: 'rt', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: 0, - minCompatibleRuntimeClientVersion: 0 - } - } - ] - ]) - }) - - const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1') - expect(runtimeHost?.health).toBe('blocked') - expect(runtimeHost?.compatibility).toMatchObject({ - kind: 'blocked', - reason: 'server-too-old' - }) - }) - - it('leaves a runtime host available when its live status is compatible', () => { - const hosts = buildSidebarHostOptions({ - repos: [], - sshTargetLabels: new Map(), - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, - runtimeStatusByEnvironmentId: new Map([ - [ - 'runtime-1', - { - checkedAt: 0, - status: { - runtimeId: 'rt', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3 - } - } - ] - ]) - }) - - const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1') - expect(runtimeHost?.health).toBe('available') - expect(runtimeHost?.compatibility?.kind).toBe('ok') - }) - - it('builds all-host plus focused-host scope options', () => { - const hosts = buildSidebarHostOptions({ - repos: [{ connectionId: 'ssh-1' }], - sshTargetLabels: new Map([['ssh-1', 'Builder']]), - settings: { activeRuntimeEnvironmentId: null } - }) - - expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([ - { id: 'all', label: 'All hosts', detail: `${LOCAL_HOST_LABEL}, Builder`, health: 'mixed' }, - { id: 'local', label: LOCAL_HOST_LABEL, health: 'local' }, - { id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' } - ]) - }) - - it('labels visible host selections for the workspace options menu', () => { - const hosts = buildSidebarHostOptions({ - repos: [{ connectionId: 'ssh-1' }], - sshTargetLabels: new Map([['ssh-1', 'Builder']]), - settings: { activeRuntimeEnvironmentId: null } - }) - - expect(getSidebarHostVisibilityLabel(null, hosts)).toBe('All hosts') - expect(getSidebarHostVisibilityLabel(['ssh:ssh-1'], hosts)).toBe('Builder') - expect(getSidebarHostVisibilityLabel(['local', 'ssh:ssh-1'], hosts)).toBe('All hosts') - }) - - it('carries host kind so the header menu can pick lifecycle actions', () => { - const hosts = buildSidebarHostOptions({ - repos: [{ connectionId: 'ssh-1' }], - sshTargetLabels: new Map([['ssh-1', 'Builder']]), - settings: { activeRuntimeEnvironmentId: 'runtime-1' } - }) - - expect(hosts.find((host) => host.id === 'local')?.kind).toBe('local') - expect(hosts.find((host) => host.id === 'ssh:ssh-1')?.kind).toBe('ssh') - expect(hosts.find((host) => host.id === 'runtime:runtime-1')?.kind).toBe('runtime') - }) - - it('labels host health for compact sidebar UI', () => { - expect(getSidebarHostHealthLabel('available')).toBe('Connected') - expect(getSidebarHostHealthLabel('connecting')).toBe('Connecting') - expect(getSidebarHostHealthLabel('blocked')).toBe('Update needed') - expect(getSidebarHostHealthLabel('blocked', 'incompatible')).toBe('Update needed') - expect(getSidebarHostHealthLabel('blocked', 'access-revoked')).toBe('Re-pair needed') - expect(getSidebarHostHealthLabel('error')).toBe('Needs attention') - }) - - it('names a host scope saved under either merged id after the merged host', () => { - const hosts = mergedHosts() - - expect(hosts.map((host) => host.id)).toEqual([ - 'local', - 'runtime:omarchy-server', - 'ssh:omarchy-target' - ]) - expect(getSidebarHostVisibilityLabel(['ssh:omarchy-target'], hosts)).toBe('Omarchy') - expect(getSidebarHostVisibilityLabel(['runtime:omarchy-server'], hosts)).toBe('Omarchy') - expect(buildSidebarHostScopeOptions(hosts).map((option) => option.id)).toEqual([ - 'all', - 'local', - 'runtime:omarchy-server' - ]) - }) - - it('keeps folder-only workspaces of the merged-away id when the merged host is chosen', () => { - const hosts = mergedHosts({ kind: 'relay', reason: 'source_changed' }) - const shown = hosts.find((host) => host.id === 'ssh:omarchy-target') - expect(shown?.aliasHostIds).toEqual(['runtime:omarchy-server']) - - const visible = toggleVisibleHostRow(hosts, null, shown!) - const group = projectGroup('runtime:omarchy-server') - - expect(new Set(visible)).toEqual(new Set(['ssh:omarchy-target', 'runtime:omarchy-server'])) - expect( - filterFolderWorkspacesForVisibleHosts( - [folderWorkspace(group.id)], - [group], - new Set(visible), - 'local' - ) - ).toHaveLength(1) - // Unchecking the row removes both ids, and all rows checked collapses to every host. - expect(toggleVisibleHostRow(hosts, ['local', ...visible!], shown!)).toEqual(['local']) - expect(toggleVisibleHostRow(hosts, ['local'], shown!)).toBeNull() - }) -}) - -function mergedHosts(managedServer?: SshConnectionState['managedServer']): SidebarHostOption[] { - return buildSidebarHostOptions({ - repos: [], - sshTargetLabels: new Map([['omarchy-target', 'Omarchy']]), - sshConnectionStates: managedServer - ? new Map([ - [ - 'omarchy-target', - { - targetId: 'omarchy-target', - status: 'connected', - error: null, - reconnectAttempt: 0, - managedServer - } - ] - ]) - : undefined, - settings: null, - runtimeEnvironments: [ - { - id: 'omarchy-server', - name: 'Omarchy', - orcadDeployment: { - sshTargetId: 'omarchy-target', - sshTargetGeneration: 1, - localPort: 7001, - remotePort: 7002 - } - } - ] - }) -} - -function projectGroup(executionHostId: string): ProjectGroup { - return { - id: 'group-1', - name: 'Notes', - parentPath: null, - executionHostId, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function folderWorkspace(projectGroupId: string): FolderWorkspace { - return { - id: 'folder-1', - projectGroupId, - name: 'notes', - folderPath: '/notes', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } -} diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts index 11f56c919a1..4aeabc4c852 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts @@ -4,7 +4,7 @@ import { useWorktreeDragRuntime } from './use-runtime' import { useWorktreePointerDragWindowEvents } from './use-pointer-window-events' import { commitWorktreePointerDrop } from './pointer-commit' import { afterEach, describe, expect, it, vi } from 'vitest' -import { flushWorktreePointerDragFrame, type WorktreePointerDragFrameArgs } from './pointer-flush' +import type { WorktreePointerDragFrameArgs } from './pointer-flush' import { NO_WORKTREE_SIDEBAR_DROP_TARGET, WORKTREE_ROW_DRAG_INITIAL_STATE } from './row-state' vi.mock('../../workspace-board/drag/workspace-kanban-sidebar-drop', () => ({ @@ -107,91 +107,6 @@ function setup() { } } -describe('combined nesting and animated reordering', () => { - it('lets the pointer cross an edge into nesting without moving the destination', () => { - const t = setup() - flushWorktreePointerDragFrame(t.args) - expect(t.state().previewOffsetsByWorktreeId.size).toBe(0) - t.nest('parent') - t.tick(80) - expect(t.state().lineageDropTargetId).toBe('parent') - expect(t.state().previewOffsetsByWorktreeId.size).toBe(0) - t.tick(200) - expect(t.state().lineageDropTargetId).toBe('parent') - expect(t.state().dropIndicatorY).toBeNull() - }) - - it('opens the reorder gap, holds it during nesting, then restores the edge preview', () => { - const t = setup() - flushWorktreePointerDragFrame(t.args) - t.tick(160) - expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) - expect(t.state().dropIndicatorY).toBe(300) - t.nest('parent') - flushWorktreePointerDragFrame(t.args) - expect(t.state().lineageDropTargetId).toBe('parent') - expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) - expect(t.state().dropIndicatorY).toBeNull() - t.nest(null) - flushWorktreePointerDragFrame(t.args) - t.tick(160) - expect(t.state().lineageDropTargetId).toBeNull() - expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) - expect(t.state().dropIndicatorY).toBe(300) - }) - - it('clears the old line during a new intent and stops scheduling after settling', () => { - const t = setup() - flushWorktreePointerDragFrame(t.args) - t.tick(160) - expect(t.state().dropIndicatorY).toBe(300) - t.args.drag.currentY = 356 - t.args.ctx.computeWorktreeDrop = () => ({ - dropIndex: 2, - dropIndicatorY: 356, - dropAnchorId: null, - previewOffsetsByWorktreeId: t.offsets - }) - flushWorktreePointerDragFrame(t.args) - expect(t.state().dropIndicatorY).toBeNull() - expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets) - t.tick(160) - expect(t.state().dropIndicatorY).toBe(356) - const frames = vi.mocked(window.requestAnimationFrame).mock.calls.length - t.tick(1000) - expect(vi.mocked(window.requestAnimationFrame).mock.calls).toHaveLength(frames) - }) -}) - -describe('stationary pointer autoscroll', () => { - it.each([1, -1])('keeps the gap tracking slots while scrolling in direction %s', (direction) => { - const t = setup() - const container = document.createElement('div') - t.args.ctx.scrollRef.current = container - let index = 10 - let offsets = new Map([['parent', 56]]) - t.args.ctx.computeWorktreeDrop = () => ({ - dropIndex: index, - dropIndicatorY: 300 - container.scrollTop, - dropAnchorId: null, - previewOffsetsByWorktreeId: offsets - }) - flushWorktreePointerDragFrame(t.args) - for (let frame = 1; frame <= 6; frame++) { - index += direction - container.scrollTop += direction * 56 - offsets = new Map([[`row-${index}`, direction * 56]]) - t.tick(80) - flushWorktreePointerDragFrame(t.args) - if (frame >= 2) { - expect(t.state().previewOffsetsByWorktreeId).toBe(offsets) - expect(t.state().dropIndicatorY).toBe(300 - container.scrollTop) - } - } - expect(t.args.drag.currentY).toBe(300) - }) -}) - describe('Escape during pointer dragging', () => { function renderDrag() { const t = setup() @@ -237,19 +152,6 @@ describe('Escape during pointer dragging', () => { expect(t.result.current.worktreeDragState).toBe(WORKTREE_ROW_DRAG_INITIAL_STATE) }) - it('leaves other keys and Escape without a drag available to the app', () => { - const t = renderDrag() - const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) - window.dispatchEvent(enter) - expect(enter.defaultPrevented).toBe(false) - expect(t.result.current.worktreePointerDragRef.current).toBe(t.args.drag) - act(() => t.result.current.clearWorktreeDrag()) - const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) - window.dispatchEvent(escape) - expect(escape.defaultPrevented).toBe(false) - expect(t.cancelBoard).toHaveBeenCalledOnce() - }) - it('removes its Escape listener on unmount', () => { const t = renderDrag() t.unmount() diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/project-header-drop.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/project-header-drop.test.ts deleted file mode 100644 index 8048a55e433..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/drag/project-header-drop.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it } from 'vitest' -import { getLogicalRepoOrderRankById } from '../../../../../../shared/project-section-order' - -import { - applyAllRepoInsertAt, - computeProjectHeaderDropPreview, - getProjectGroupOrderForSidebarDrop, - getProjectHeaderDragBucketKey, - getSidebarOrderedRepoHeaderIdsByBucket, - mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, - mapSidebarRepoDropIndexToAllRepoInsertAt -} from './project-header-drop' -import type { Row } from '../../../../../../shared/sidebar-list/row-types' -import type { Repo } from '../../../../../../shared/repo-types' - -describe('getProjectHeaderDragBucketKey', () => { - it('uses ungrouped for repos without a project group', () => { - expect(getProjectHeaderDragBucketKey({ projectGroupId: undefined })).toBe('ungrouped') - }) - - it('scopes grouped repos to their project group bucket', () => { - expect(getProjectHeaderDragBucketKey({ projectGroupId: 'group-a' })).toBe('group:group-a') - }) -}) - -describe('getSidebarOrderedRepoHeaderIdsByBucket', () => { - it('groups repo headers by project group membership', () => { - const rows = [ - { - type: 'header', - key: 'repo:a', - label: 'A', - count: 1, - repo: { id: 'a', projectGroupId: 'group-a' } - }, - { - type: 'header', - key: 'repo:b', - label: 'B', - count: 1, - repo: { id: 'b' } - } - ] as Row[] - - expect(getSidebarOrderedRepoHeaderIdsByBucket(rows)).toEqual( - new Map([ - ['group:group-a', ['a']], - ['ungrouped', ['b']] - ]) - ) - }) - - it("orders a host copy by its project's anchor and skips copies already drawn", () => { - const repo = (id: string): Repo => ({ - id, - path: `/${id}`, - displayName: id, - badgeColor: '#000000', - addedAt: 0 - }) - const header = (key: string, fields: Partial>): Row => ({ - type: 'header', - key, - count: 1, - icon: { kind: 'project' }, - ...fields - }) - const rows = [ - header('project:p', { repo: repo('p-ssh'), anchorRepo: repo('p-local'), hostId: 'ssh:b' }), - header('project:q', { repo: repo('q'), hostId: 'ssh:b' }), - header('project:p', { - repo: repo('p-local'), - anchorRepo: repo('p-local'), - hostId: 'local', - repeated: true - }) - ] - - expect(getSidebarOrderedRepoHeaderIdsByBucket(rows)).toEqual( - new Map([['ungrouped', ['p-local', 'q']]]) - ) - }) -}) - -describe('getLogicalRepoOrderRankById', () => { - it('anchors a merged paired-host header to its first persisted occurrence', () => { - const rankById = getLogicalRepoOrderRankById(['b', 'same', 'c', 'same']) - - expect(rankById).toEqual( - new Map([ - ['b', 0], - ['same', 1], - ['c', 2] - ]) - ) - }) -}) - -describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => { - const sidebar = ['a', 'b', 'c'] - - it('maps sidebar start drops onto the first visible repo in the full list', () => { - expect(mapSidebarRepoDropIndexToAllRepoInsertAt(0, sidebar, ['hidden', 'a', 'b', 'c'])).toBe(1) - }) - - it('maps sidebar end drops onto the slot after the last visible repo', () => { - expect(mapSidebarRepoDropIndexToAllRepoInsertAt(3, sidebar, ['a', 'hidden', 'b', 'c'])).toBe(4) - }) - - it('maps middle sidebar drops onto the target repo id in the full list', () => { - expect(mapSidebarRepoDropIndexToAllRepoInsertAt(2, sidebar, ['a', 'hidden', 'b', 'c'])).toBe(3) - }) -}) - -describe('mapSidebarProjectHeaderDropIndexToSiblingInsertIndex', () => { - it('keeps upward drops at the same target index after removing the source', () => { - expect( - mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ - sidebarDropIndex: 0, - sourceIndex: 2, - siblingCount: 2 - }) - ).toBe(0) - }) - - it('shifts downward drops because the source header is removed first', () => { - expect( - mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ - sidebarDropIndex: 3, - sourceIndex: 0, - siblingCount: 2 - }) - ).toBe(2) - }) - - it('maps a drop immediately after the source back to the original slot', () => { - expect( - mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({ - sidebarDropIndex: 2, - sourceIndex: 1, - siblingCount: 2 - }) - ).toBe(1) - }) -}) - -describe('computeProjectHeaderDropPreview', () => { - it('uses row-model header indices instead of mounted subset order', () => { - const preview = computeProjectHeaderDropPreview({ - pointerY: 105, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b', 'c', 'd', 'e'], - rects: [ - { repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: 100, bottom: 128 }, - { repoId: 'c', bucketKey: 'ungrouped', headerIndex: 2, top: 200, bottom: 228 }, - { repoId: 'd', bucketKey: 'ungrouped', headerIndex: 3, top: 300, bottom: 328 } - ] - }) - - expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) - }) - - it('supports boundary drops at the end of the full sidebar list', () => { - const preview = computeProjectHeaderDropPreview({ - pointerY: 400, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b', 'c'], - rects: [ - { - repoId: 'c', - bucketKey: 'ungrouped', - headerIndex: 2, - top: 300, - bottom: 328, - sectionBottom: 380 - } - ] - }) - - expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 383 }) - }) - - it('snaps a drop inside the last expanded project section to its bottom boundary', () => { - const INDICATOR_GAP = 4 - const sectionBottom = 380 - const preview = computeProjectHeaderDropPreview({ - pointerY: 350, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b', 'c'], - rects: [ - { - repoId: 'c', - bucketKey: 'ungrouped', - headerIndex: 2, - top: 300, - bottom: 328, - sectionBottom - } - ] - }) - - expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: sectionBottom + INDICATOR_GAP }) - }) - - it('snaps a drop between sibling project headers to the nearer boundary', () => { - const INDICATOR_GAP = 4 - const nextHeaderTop = 220 - const preview = computeProjectHeaderDropPreview({ - pointerY: 150, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b'], - rects: [ - { - repoId: 'a', - bucketKey: 'ungrouped', - headerIndex: 0, - top: 100, - bottom: 128, - sectionBottom: nextHeaderTop - }, - { repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: nextHeaderTop, bottom: 248 } - ] - }) - - expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderTop - INDICATOR_GAP }) - }) - - describe('nearest-boundary choice across an interior gap', () => { - // Estimated section ends can drift from actual header geometry; test snapping on both sides of the gap. - const INDICATOR_GAP = 4 - const prevSectionBottom = 200 - const nextHeaderTop = 240 - const sectionBottomSlotY = prevSectionBottom + INDICATOR_GAP - const nextHeaderSlotY = nextHeaderTop - INDICATOR_GAP - const midpointY = (sectionBottomSlotY + nextHeaderSlotY) / 2 - const gapRects = [ - { - repoId: 'a', - bucketKey: 'ungrouped', - headerIndex: 0, - top: 100, - bottom: 128, - sectionBottom: prevSectionBottom - }, - { - repoId: 'b', - bucketKey: 'ungrouped', - headerIndex: 1, - top: nextHeaderTop, - bottom: 268, - sectionBottom: 340 - } - ] as const - - it('snaps to the previous section bottom when the pointer is nearer to it', () => { - const preview = computeProjectHeaderDropPreview({ - pointerY: sectionBottomSlotY + 1, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b'], - rects: gapRects.map((rect) => ({ ...rect })) - }) - - expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: sectionBottomSlotY }) - }) - - it('snaps to the next header top when the pointer is nearer to it', () => { - const preview = computeProjectHeaderDropPreview({ - pointerY: nextHeaderSlotY - 1, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b'], - rects: gapRects.map((rect) => ({ ...rect })) - }) - - expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderSlotY }) - }) - - it('breaks the midpoint tie toward the next header boundary', () => { - const preview = computeProjectHeaderDropPreview({ - pointerY: midpointY, // Equidistant boundaries prefer the next header. - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b'], - rects: gapRects.map((rect) => ({ ...rect })) - }) - - expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderSlotY }) - }) - }) - - describe('content bound for the last section', () => { - const INDICATOR_GAP = 4 - const estimatedSectionBottom = 380 - const lastRects = [ - { - repoId: 'c', - bucketKey: 'ungrouped', - headerIndex: 2, - top: 300, - bottom: 328, - sectionBottom: estimatedSectionBottom - } - ] as const - const lastPreview = (pointerY: number, contentBottom: number) => - computeProjectHeaderDropPreview({ - pointerY, - containerTop: 0, - scrollTop: 0, - sidebarRepoHeaderIds: ['a', 'b', 'c'], - rects: lastRects.map((rect) => ({ ...rect })), - contentBottom - }) - - it('rejects a drop below the measured content when the estimate overshoots', () => { - expect(lastPreview(360, 340)).toBeNull() - }) - - it('rejects an edge-zone final drop below measured content when the estimate overshoots', () => { - // Cross the estimated end while remaining below measured content; the measured bound must win. - expect(lastPreview(389, 350)).toBeNull() - }) - - it('still snaps within the measured content when the estimate overshoots', () => { - expect(lastPreview(335, 340)).toEqual({ - dropIndex: 3, - dropIndicatorY: estimatedSectionBottom + INDICATOR_GAP - }) - }) - - it('snaps within the measured content when actual content undershoots the estimate', () => { - expect(lastPreview(360, 420)).toEqual({ - dropIndex: 3, - dropIndicatorY: estimatedSectionBottom + INDICATOR_GAP - }) - }) - }) -}) - -describe('applyAllRepoInsertAt', () => { - it('reorders repos using a full-list insertion index', () => { - expect(applyAllRepoInsertAt(['hidden', 'a', 'b', 'c'], 'c', 1)).toEqual([ - 'hidden', - 'c', - 'a', - 'b' - ]) - }) - - it('moves duplicate host occurrences as one stable logical-project block', () => { - expect(applyAllRepoInsertAt(['b', 'same', 'c', 'same'], 'same', 0)).toEqual([ - 'same', - 'same', - 'b', - 'c' - ]) - }) - - it('returns null for no-op reorders', () => { - expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull() - }) -}) - -describe('getProjectGroupOrderForSidebarDrop', () => { - const repo = (id: string, projectGroupOrder?: number): Repo => - ({ - id, - path: `/${id}`, - displayName: id, - badgeColor: '#000', - addedAt: 0, - projectGroupOrder - }) as Repo - - it('uses a midpoint between sibling orders when there is room', () => { - expect( - getProjectGroupOrderForSidebarDrop({ - siblings: [repo('a', 0), repo('b', 10)], - dropIndex: 1 - }) - ).toBe(5) - }) - - it('uses manual repo rank as the fallback for missing sibling orders', () => { - expect( - getProjectGroupOrderForSidebarDrop({ - siblings: [repo('a'), repo('c')], - dropIndex: 1, - repoOrderRankById: new Map([ - ['a', 0], - ['b', 1], - ['c', 2] - ]) - }) - ).toBe(1000) - }) - - it('keeps a deterministic finite anchor when sibling orders collide', () => { - expect( - getProjectGroupOrderForSidebarDrop({ - siblings: [repo('a', 0), repo('b', 0)], - dropIndex: 1 - }) - ).toBe(1) - }) - - it('assigns an order that sorts before siblings ranked by repo order', () => { - const order = getProjectGroupOrderForSidebarDrop({ - siblings: [repo('a'), repo('b')], - dropIndex: 1, - repoOrderRankById: new Map([ - ['a', 0], - ['b', 1], - ['c', 2] - ]) - }) - - expect(order).toBeGreaterThan(0) - expect(order).toBeLessThan(2000) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts deleted file mode 100644 index 50c3f6bb46e..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -// @vitest-environment happy-dom - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, renderHook } from '@testing-library/react' -import { useAppStore } from '@/store' -import { getWorktreeMapFromState } from '@/store/selectors' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../../shared/workspace-status-defaults' -import { - makeFolderWorkspace, - makeWorktree -} from '../../../worktree-jump/worktree-jump-palette-test-fixtures' -import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' -import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' -import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspaces/folder-workspace-worktree' -import { useWorktreeStatusMutations } from './use-status-mutations' -import { useWorktreeDropCommitContext } from './use-drop-commit-context' -import { useWorktreeDragSession } from './use-session' -import type { WorktreeDropCommitContext } from './drop-commit-context' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), dismiss: vi.fn() } })) - -const REPO_ID = 'repo-twin' -const TWIN_ID = `${REPO_ID}::/src/app` -const OTHER_ID = `${REPO_ID}::/src/other` -const SSH: ExecutionHostId = 'ssh:build-box' -const initialState = useAppStore.getInitialState() -const updateWorktreesMeta = vi.fn().mockResolvedValue(undefined) - -function seedTwins(extra: Worktree[] = []): { local: Worktree; ssh: Worktree; dragged: Worktree } { - const local = makeWorktree(TWIN_ID, 'app', { repoId: REPO_ID, hostId: 'local' }) - const ssh = makeWorktree(TWIN_ID, 'app', { repoId: REPO_ID, hostId: SSH }) - const other = makeWorktree(OTHER_ID, 'other', { repoId: REPO_ID, hostId: 'local' }) - useAppStore.setState({ - sortBy: 'manual', - worktreesByRepo: { [REPO_ID]: [local, other, ssh, ...extra] }, - updateWorktreesMeta - }) - // Drag whichever copy the id-keyed map did not keep, so a by-id lookup picks the wrong one. - const kept = getWorktreeMapFromState(useAppStore.getState()).get(TWIN_ID) - return { local, ssh, dragged: kept?.hostId === SSH ? local : ssh } -} - -function renderMutations(sortBy: 'manual' | 'smart' = 'manual') { - const worktrees = Object.values(useAppStore.getState().worktreesByRepo).flat() - return renderHook(() => - useWorktreeStatusMutations({ - manualOrderCatalog: buildWorktreeManualOrderCatalog({ worktrees, folderWorkspaces: [] }), - workspaceStatuses: DEFAULT_WORKSPACE_STATUSES, - sortBy - }) - ).result -} - -function written(): { - worktreeId: string - updates?: Record - executionHostId?: string -}[] { - return updateWorktreesMeta.mock.calls[0]?.[0] ?? [] -} - -function writtenHosts( - worktreeId = TWIN_ID, - field?: 'workspaceStatus' | 'manualOrder' | 'isPinned' -) { - return written() - .filter((u) => u.worktreeId === worktreeId && (!field || field in (u.updates ?? {}))) - .map((u) => u.executionHostId) - .sort() -} - -const copyOf = (row: Worktree) => [{ id: row.id, hostId: row.hostId }] -const keyOf = getWorktreeHostIdentity - -describe('a sidebar drop on one card of a twin', () => { - beforeEach(() => { - useAppStore.setState(initialState, true) - updateWorktreesMeta.mockClear() - }) - afterEach(() => { - cleanup() - useAppStore.setState(initialState, true) - }) - - it('writes the status to the dragged copy, not the twin the id-keyed map kept', async () => { - const { dragged } = seedTwins() - const mutations = renderMutations() - - await act(async () => { - mutations.current.moveWorktreesToStatusAtIndex({ - worktreeIds: [TWIN_ID], - status: 'in-review', - dropIndex: 0, - groups: [{ key: 'in-review', worktreeIds: [] }], - draggedCopies: copyOf(dragged) - }) - }) - - expect(writtenHosts(TWIN_ID, 'workspaceStatus')).toEqual([dragged.hostId]) - }) - - // The sidebar ranks each card: same-id copies on two machines reorder independently. - it("writes a dragged twin's new rank to that copy only", async () => { - const { local, ssh, dragged } = seedTwins() - const other = useAppStore.getState().worktreesByRepo[REPO_ID]!.find((w) => w.id === OTHER_ID)! - const ranked = { [keyOf(local)]: 300, [keyOf(other)]: 200, [keyOf(ssh)]: 100 } - useAppStore.setState({ - worktreesByRepo: { - [REPO_ID]: useAppStore - .getState() - .worktreesByRepo[REPO_ID]!.map((row) => ({ ...row, manualOrder: ranked[keyOf(row)] })) - } - }) - const mutations = renderMutations() - - await act(async () => { - mutations.current.reorderWorktrees({ - groups: [{ key: 'all', worktreeIds: [keyOf(local), keyOf(other), keyOf(ssh)] }], - sourceGroupKey: 'all', - draggedKeys: [keyOf(dragged)], - dropIndex: dragged === ssh ? 1 : 3, - draggedCopies: copyOf(dragged) - }) - }) - - expect(writtenHosts(TWIN_ID, 'manualOrder')).toEqual([dragged.hostId]) - expect(writtenHosts(OTHER_ID)).toEqual([]) - }) - - it('ranks every copy of a twin when a drop first materializes the order', async () => { - const { local, ssh } = seedTwins() - const mutations = renderMutations() - const other = useAppStore.getState().worktreesByRepo[REPO_ID]!.find((w) => w.id === OTHER_ID)! - - await act(async () => { - // Drag the unique row above both twin cards; neither twin is dragged, but every card gets a rank. - mutations.current.reorderWorktrees({ - groups: [{ key: 'all', worktreeIds: [keyOf(local), keyOf(ssh), keyOf(other)] }], - sourceGroupKey: 'all', - draggedKeys: [keyOf(other)], - dropIndex: 0, - draggedCopies: copyOf(other) - }) - }) - - expect(writtenHosts(TWIN_ID, 'manualOrder')).toEqual(['local', SSH]) - expect(writtenHosts(OTHER_ID)).toEqual(['local']) - }) - - it('still ranks an unranked folder workspace that shares the manual-order catalog', async () => { - const folder = makeFolderWorkspace({ id: 'folder-unranked' }) - const a = makeWorktree(`${REPO_ID}::/a`, 'a', { repoId: REPO_ID, hostId: 'local' }) - const b = makeWorktree(`${REPO_ID}::/b`, 'b', { repoId: REPO_ID, hostId: 'local' }) - useAppStore.setState({ - sortBy: 'manual', - worktreesByRepo: { [REPO_ID]: [a, b] }, - folderWorkspaces: [folder], - updateWorktreesMeta - }) - const catalog = buildWorktreeManualOrderCatalog({ - worktrees: [a, b], - folderWorkspaces: [folder] - }) - const { result } = renderHook(() => - useWorktreeStatusMutations({ - manualOrderCatalog: catalog, - workspaceStatuses: DEFAULT_WORKSPACE_STATUSES, - sortBy: 'manual' - }) - ) - - await act(async () => { - result.current.reorderWorktrees({ - groups: [{ key: 'all', worktreeIds: [keyOf(a), keyOf(b)] }], - sourceGroupKey: 'all', - draggedKeys: [keyOf(b)], - dropIndex: 0, - draggedCopies: copyOf(b) - }) - }) - - expect(written().map((u) => u.worktreeId)).toContain(folderWorkspaceKey(folder.id)) - }) - - it('lands a drop next to an unranked twin when the ranks are first materialized', async () => { - const b = makeWorktree(`${REPO_ID}::/b`, 'b', { - repoId: REPO_ID, - hostId: 'local', - sortOrder: 2 - }) - const a = makeWorktree(`${REPO_ID}::/a`, 'a', { - repoId: REPO_ID, - hostId: 'local', - sortOrder: 1 - }) - const aOnSsh = { ...a, hostId: SSH } - useAppStore.setState({ - sortBy: 'manual', - worktreesByRepo: { [REPO_ID]: [b, a, aOnSsh] }, - updateWorktreesMeta - }) - const mutations = renderMutations() - - await act(async () => { - mutations.current.reorderWorktrees({ - groups: [{ key: 'all', worktreeIds: [keyOf(b), keyOf(a)] }], - sourceGroupKey: 'all', - draggedKeys: [keyOf(b)], - dropIndex: 2, - draggedCopies: copyOf(b) - }) - }) - - const applied = [b, a, aOnSsh].map((row) => { - const write = written().find( - (u) => u.worktreeId === row.id && u.executionHostId === row.hostId - ) - return write ? { ...row, ...write.updates } : row - }) - expect( - buildWorktreeManualOrderCatalog({ worktrees: applied, folderWorkspaces: [] }).orderedKeys - ).toEqual([keyOf(a), keyOf(b), keyOf(aOnSsh)]) - }) - - it('ranks a selected folder workspace dragged together with a git card', async () => { - const folder = makeFolderWorkspace({ id: 'folder-selected', executionHostId: 'local' }) - const a = makeWorktree(`${REPO_ID}::/a`, 'a', { repoId: REPO_ID, hostId: 'local' }) - const b = makeWorktree(`${REPO_ID}::/b`, 'b', { repoId: REPO_ID, hostId: 'local' }) - useAppStore.setState({ - sortBy: 'manual', - worktreesByRepo: { [REPO_ID]: [a, b] }, - folderWorkspaces: [folder], - updateWorktreesMeta - }) - const folderId = folderWorkspaceKey(folder.id) - const folderKey = keyOf(folderWorkspaceToWorktree(folder)) - const mutations = renderMutations() - - await act(async () => { - mutations.current.reorderWorktrees({ - groups: [{ key: 'all', worktreeIds: [keyOf(a), keyOf(b), folderKey] }], - sourceGroupKey: 'all', - draggedKeys: [keyOf(b), folderKey], - dropIndex: 0, - draggedCopies: [...copyOf(b), { id: folderId, hostId: 'local' }] - }) - }) - - expect(written().map((u) => u.worktreeId)).toContain(folderId) - }) - - it('refuses a dragged copy that left its host mid-drag rather than writing its twin', async () => { - const { dragged } = seedTwins() - useAppStore.setState({ - worktreesByRepo: { - [REPO_ID]: useAppStore.getState().worktreesByRepo[REPO_ID]!.filter((w) => w !== dragged) - } - }) - const mutations = renderMutations() - - await act(async () => { - mutations.current.moveWorktreesToStatus([TWIN_ID], 'in-review', copyOf(dragged)) - }) - - expect(updateWorktreesMeta).not.toHaveBeenCalled() - }) - - it("carries a dragged twin's own lineage children, with their host, not the other copy's", () => { - const { dragged } = seedTwins() - const twinOf: ExecutionHostId = dragged.hostId === SSH ? 'local' : SSH - const childOnDraggedHost = makeWorktree(`${REPO_ID}::/src/child-a`, 'child-a', { - repoId: REPO_ID, - hostId: dragged.hostId - }) - const childOfTwin = makeWorktree(`${REPO_ID}::/src/child-b`, 'child-b', { - repoId: REPO_ID, - hostId: twinOf - }) - const twin = { ...dragged, hostId: twinOf } - const item = (worktree: Worktree, depth: number): HostSectionRow => ({ - type: 'item', - rowKey: `${worktree.hostId}|${worktree.id}`, - sectionKey: 'all', - worktree, - repo: undefined, - depth, - groupDepth: 0, - isLastLineageChild: depth > 0, - lineageChildCount: depth === 0 ? 1 : 0 - }) - const rows = [ - item(dragged, 0), - item(childOnDraggedHost, 1), - item(twin, 0), - item(childOfTwin, 1) - ] - const { result } = renderHook(() => - useWorktreeDragSession({ rows, scrollRef: { current: null } }) - ) - - expect(result.current.getReorderDraggedCopies(copyOf(dragged))).toEqual([ - { id: dragged.id, hostId: dragged.hostId }, - { id: childOnDraggedHost.id, hostId: dragged.hostId } - ]) - }) - - it('previews the board insertion line from the dragged copy, as the commit reads it', () => { - seedTwins() - const kept = getWorktreeMapFromState(useAppStore.getState()).get(TWIN_ID) - // The kept twin already sits in the target lane; the dragged copy does not. - useAppStore.setState({ - sortBy: 'smart', - worktreesByRepo: { - [REPO_ID]: useAppStore - .getState() - .worktreesByRepo[REPO_ID]!.map((row) => - row === kept - ? { ...row, workspaceStatus: 'in-review' } - : { ...row, workspaceStatus: 'todo' } - ) - } - }) - const dragged = useAppStore - .getState() - .worktreesByRepo[REPO_ID]!.find((row) => row.id === TWIN_ID && row.hostId !== kept?.hostId)! - const mutations = renderMutations('smart') - - expect( - mutations.current.shouldShowWorkspaceBoardDropIndicator( - [TWIN_ID], - 'in-review', - copyOf(dragged) - ) - ).toBe(false) - }) - - it('pins the dragged copy of a twin dropped on Pinned, not the copy the id-keyed map kept', async () => { - const { dragged } = seedTwins() - const mutations = renderMutations() - - await act(async () => { - mutations.current.pinWorktrees([TWIN_ID], copyOf(dragged)) - }) - - expect(writtenHosts(TWIN_ID, 'isPinned')).toEqual([dragged.hostId]) - }) - - it('refuses a host-less pin of an id two hosts publish', async () => { - seedTwins() - const mutations = renderMutations() - - await act(async () => { - mutations.current.pinWorktree(TWIN_ID) - useAppStore.getState().setWorktreesPinnedAndReveal([{ id: TWIN_ID }], true) - }) - - expect(updateWorktreesMeta).not.toHaveBeenCalled() - }) - - it('hands the drop the copies the active drag picked up', () => { - const onPinWorktrees = vi.fn() - const onMoveWorktreesToStatusAtIndex = vi.fn() - const onReorderWorktrees = vi.fn() - const onMoveWorktreesToStatus = vi.fn() - const draggedCopies = [{ id: TWIN_ID, hostId: SSH }] as const - const { result } = renderHook(() => - useWorktreeDropCommitContext({ - scrollRef: { current: null }, - workspaceStatuses: [], - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the context reads only the drag-session ref and pass-through fields here. - session: { worktreeDragSessionRef: { current: { draggedCopies } } } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, only the pointer-drag ref is read. - lineageDrop: {} as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, only the pointer-drag ref is read. - runtime: { worktreePointerDragRef: { current: null } } as never, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - onPinWorktrees - }) - ) - const ctx: WorktreeDropCommitContext = result.current - - ctx.onMoveWorktreesToStatus([TWIN_ID], 'done') - ctx.onMoveWorktreesToStatusAtIndex({ - worktreeIds: [TWIN_ID], - status: 'done', - dropIndex: 0, - groups: [] - }) - ctx.onReorderWorktrees({ - groups: [], - sourceGroupKey: 'all', - draggedKeys: [keyOf({ id: TWIN_ID, hostId: SSH })], - dropIndex: 0 - }) - - expect(onMoveWorktreesToStatus).toHaveBeenCalledWith([TWIN_ID], 'done', draggedCopies) - expect(onMoveWorktreesToStatusAtIndex.mock.calls[0][0].draggedCopies).toBe(draggedCopies) - expect(onReorderWorktrees.mock.calls[0][0].draggedCopies).toBe(draggedCopies) - ctx.onPinWorktrees([TWIN_ID]) - expect(onPinWorktrees).toHaveBeenCalledWith([TWIN_ID], draggedCopies) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/twin-reorder.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/twin-reorder.test.ts deleted file mode 100644 index 218bc23f06c..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/drag/twin-reorder.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' -import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' -import { createHostScopedRepoOf } from '../../../../../../shared/sidebar-list/sidebar-feed-index' -import { - NO_LIVE_SMART_SORT_SOURCES, - rankWorktreesForSort -} from '../../../../../../shared/sidebar-list/smart-sort' -import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' -import { computeVisibleWorktrees } from '../listing/compute-visible-worktrees' -import { repo, worktree } from '../grouping/worktree-list-groups-test-fixtures' -import { - buildManualOrderUpdatesForGroupDrop, - buildManualOrderUpdatesForVisibleGroups -} from './worktree-manual-order' -import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' -import { collectDropWrites, dropWriteRows } from './dragged-worktree-copies' -import { getWorktreeDragGroups } from './groups' - -// Per-workspace VMs cloned from one base share the repo id and path, so every primary has the same id. -const REPO_ID = 'repo-teal' -const TWIN_ID = `${REPO_ID}::/home/dev/teal` -const HOSTS = ['ssh:vm-a', 'ssh:vm-b', 'ssh:vm-c'] as const satisfies readonly ExecutionHostId[] -const repos: Repo[] = HOSTS.map((hostId) => ({ - ...repo, - id: REPO_ID, - path: '/home/dev/teal', - displayName: 'teal', - connectionId: hostId.slice('ssh:'.length) -})) -const repoMap = new Map([[REPO_ID, repos[0]!]]) - -function primary(hostId: ExecutionHostId, manualOrder: number): Worktree { - return { - ...worktree, - id: TWIN_ID, - repoId: REPO_ID, - path: '/home/dev/teal', - hostId, - isMainWorktree: true, - displayName: hostId, - manualOrder - } -} - -/** The window's Manual path: rank, filter, plan rows, then read the cards' hosts in order. */ -function renderedHosts(worktrees: Worktree[]): string[] { - const sortedKeys = rankWorktreesForSort({ - worktrees: [...worktrees], - sortBy: 'manual', - latched: false, - repoMap, - now: 0, - sources: NO_LIVE_SMART_SORT_SOURCES - }).sorted.map(getWorktreeHostIdentity) - const visible = computeVisibleWorktrees({ [REPO_ID]: worktrees }, sortedKeys, { - filterRepoIds: [], - showSleepingWorkspaces: true, - tabsByWorktree: {}, - ptyIdsByTabId: {}, - worktreeIdsWithLiveAgent: new Set(), - hideDefaultBranchWorkspace: false, - hideAutomationGeneratedWorkspaces: false, - hideCliCreatedWorkspaces: false, - hideDetachedHeadWorkspaces: false, - hideWorkspacesFromOtherDevices: false, - pairedDeviceIdsByEnvironment: new Map(), - repoMap, - workspaceHostScope: 'all', - defaultHostId: 'local', - worktreeLineageById: {}, - sortedByHostIdentity: true - }) - return planRows(visible).flatMap((row) => (row.type === 'item' ? [row.worktree.hostId!] : [])) -} - -function planRows(worktrees: Worktree[]) { - return planSidebarRows({ - groupBy: 'repo', - sortBy: 'manual', - worktrees, - repoMap, - repoOfWorktree: createHostScopedRepoOf(repos, 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()) -} - -/** Drop one card at a slot the way the sidebar commits it, and apply the writes it would send. */ -function dropCard(worktrees: Worktree[], hostId: ExecutionHostId, dropIndex: number) { - const groups = getWorktreeDragGroups(planRows(worktrees)) - const catalog = buildWorktreeManualOrderCatalog({ worktrees, folderWorkspaces: [] }) - const dragged = worktrees.find((row) => row.hostId === hostId)! - const result = buildManualOrderUpdatesForVisibleGroups({ - groups, - sourceGroupKey: groups[0]!.key, - draggedIds: [getWorktreeHostIdentity(dragged)], - dropIndex, - now: 1_000_000, - rankByWorktreeId: catalog.rankByKey, - allWorktreeIds: catalog.orderedKeys - }) - const writes = collectDropWrites({ - rowsFor: dropWriteRows( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dropWriteRows reads only these two slices. - { worktreesByRepo: { [REPO_ID]: worktrees }, folderWorkspaces: [] } as never, - [{ id: dragged.id, hostId: dragged.hostId }] - ), - statusIds: [], - status: null, - workspaceStatuses: cloneDefaultWorkspaceStatuses(), - orderUpdatesByKey: result.updates - }) - const applied = worktrees.map((row) => { - const write = writes.find((entry) => entry.executionHostId === row.hostId) - return write ? { ...row, ...write.updates } : row - }) - return { groups, writes, applied } -} - -describe('reordering same-id primaries on separate machines', () => { - const twins = [primary('ssh:vm-a', 300), primary('ssh:vm-b', 200), primary('ssh:vm-c', 100)] - - it('renders each copy at its own rank', () => { - expect(renderedHosts(twins)).toEqual(['ssh:vm-a', 'ssh:vm-b', 'ssh:vm-c']) - expect(renderedHosts([twins[2]!, twins[0]!, twins[1]!])).toEqual([ - 'ssh:vm-a', - 'ssh:vm-b', - 'ssh:vm-c' - ]) - }) - - it('gives every copy its own drag slot', () => { - const { groups } = dropCard(twins, 'ssh:vm-c', 0) - expect(groups[0]!.worktreeIds).toEqual(HOSTS.map((hostId) => `${hostId}|${TWIN_ID}`)) - }) - - it('moves the dropped copy to the top and writes only that copy', () => { - const { writes, applied } = dropCard(twins, 'ssh:vm-c', 0) - - expect(writes.map((entry) => entry.executionHostId)).toEqual(['ssh:vm-c']) - expect(renderedHosts(applied)).toEqual(['ssh:vm-c', 'ssh:vm-a', 'ssh:vm-b']) - }) - - it('moves a copy down past its twins', () => { - const { applied } = dropCard(twins, 'ssh:vm-a', 3) - - expect(renderedHosts(applied)).toEqual(['ssh:vm-b', 'ssh:vm-c', 'ssh:vm-a']) - }) - - const feature = { - ...primary('ssh:vm-c', 50), - id: `${REPO_ID}::/home/dev/feature`, - path: '/home/dev/feature', - isMainWorktree: false - } - - it('drops a ranked primary past a linked worktree, writing only that primary', () => { - const { writes, applied } = dropCard([...twins.slice(0, 2), feature], 'ssh:vm-b', 3) - - expect(writes.map((entry) => entry.executionHostId)).toEqual(['ssh:vm-b']) - expect(renderedHosts(applied)).toEqual(['ssh:vm-a', 'ssh:vm-c', 'ssh:vm-b']) - }) - - it('drops a primary past a linked worktree when unranked rows materialize ranks', () => { - const unranked = [...twins.slice(0, 2), feature].map( - ({ manualOrder: _manualOrder, ...row }) => row - ) - const { writes, applied } = dropCard(unranked, 'ssh:vm-b', 3) - - expect(writes.map((entry) => entry.executionHostId).sort()).toEqual([...HOSTS]) - expect(renderedHosts(applied)).toEqual(['ssh:vm-a', 'ssh:vm-c', 'ssh:vm-b']) - }) - - it('materializes a rank for every copy when none has one yet', () => { - const unranked = twins.map(({ manualOrder: _manualOrder, ...row }, index) => ({ - ...row, - sortOrder: 30 - index * 10 - })) - const { writes, applied } = dropCard(unranked, 'ssh:vm-b', 0) - - expect(writes.map((entry) => entry.executionHostId).sort()).toEqual([...HOSTS]) - expect(renderedHosts(applied)).toEqual(['ssh:vm-b', 'ssh:vm-a', 'ssh:vm-c']) - }) - - it('places a board drop next to the lower copy, writing only the dragged card', () => { - // Lane a, vm-a copy, b, c, vm-b copy, d: the copies sit apart after a sidebar reorder. - const card = (name: string, manualOrder: number): Worktree => ({ - ...worktree, - id: `${REPO_ID}::/w/${name}`, - repoId: REPO_ID, - path: `/w/${name}`, - hostId: 'ssh:vm-a', - displayName: name, - manualOrder - }) - const lane = [ - card('a', 0), - primary('ssh:vm-a', -1_000), - card('b', -2_000), - card('c', -3_000), - primary('ssh:vm-b', -3_500), - card('d', -4_000) - ] - const catalog = buildWorktreeManualOrderCatalog({ worktrees: lane, folderWorkspaces: [] }) - const drop = (dropIndex: number) => - buildManualOrderUpdatesForGroupDrop({ - groups: [{ key: 'todo', worktreeIds: lane.map(getWorktreeHostIdentity) }], - targetGroupKey: 'todo', - draggedIds: [getWorktreeHostIdentity(lane[0]!)], - dropIndex, - now: 2_000_000, - rankByWorktreeId: catalog.rankByKey, - allWorktreeIds: catalog.orderedKeys - }) - - // Just below the vm-b copy: between it and d. - expect([...drop(5).updates]).toEqual([ - [getWorktreeHostIdentity(lane[0]!), { manualOrder: -3_750 }] - ]) - // Between c and the vm-b copy: still sparse, no full reindex. - expect([...drop(4).updates]).toEqual([ - [getWorktreeHostIdentity(lane[0]!), { manualOrder: -3_250 }] - ]) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-lineage-drag-drop.test.ts index 0f2005b8c9e..498aa8c8adf 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-lineage-drag-drop.test.ts @@ -1,95 +1,10 @@ // @vitest-environment happy-dom import { describe, expect, it } from 'vitest' -import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { getCyclicProjectedWorktreeLineageIds } from '../../../../../../shared/sidebar-list/worktree-lineage-projection' -import { - getReorderedWorktreeIdsToUnnest, - getWorktreeLineageDropTargetId, - isWorktreeLineageDropZoneHit -} from './worktree-lineage-drag-drop' -describe('isWorktreeLineageDropZoneHit', () => { - it('keeps the top and bottom of a card available for reorder drops', () => { - const rect = { top: 100, bottom: 200 } as DOMRect - - expect(isWorktreeLineageDropZoneHit({ pointerY: 107, rect })).toBe(false) - expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 193, rect })).toBe(false) - }) - - it('makes most of tall cards available for nesting', () => { - const rect = { top: 0, bottom: 180 } as DOMRect - - expect(isWorktreeLineageDropZoneHit({ pointerY: 8, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 172, rect })).toBe(true) - }) - - it('scales down reorder gutters for compact cards', () => { - const rect = { top: 100, bottom: 120 } - - expect(isWorktreeLineageDropZoneHit({ pointerY: 103, rect })).toBe(false) - expect(isWorktreeLineageDropZoneHit({ pointerY: 104, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 116, rect })).toBe(true) - expect(isWorktreeLineageDropZoneHit({ pointerY: 117, rect })).toBe(false) - }) - - it.each([ - { top: 100, bottom: 100 }, - { top: 100, bottom: 90 } - ])('rejects empty and inverted rectangles', (rect) => { - expect(isWorktreeLineageDropZoneHit({ pointerY: 100, rect })).toBe(false) - }) -}) +import { getWorktreeLineageDropTargetId } from './worktree-lineage-drag-drop' describe('getWorktreeLineageDropTargetId', () => { - it('returns the row id only when the pointer is away from the reorder gutters', () => { - const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) - - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 107 })).toBeNull() - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') - }) - - it('ignores content targets outside the sidebar container', () => { - const { container, target } = makeTarget({ - worktreeId: 'parent', - top: 100, - bottom: 200, - contained: false - }) - - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() - }) - - it.each(['status', 'agent'] as const)( - 'keeps the %s region in the lineage nesting hit zone', - (targetRole) => { - const { container, target } = makeTarget({ - worktreeId: 'parent', - top: 100, - bottom: 200, - targetRole - }) - - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') - } - ) - - it('accepts horizontal card padding outside the content element', () => { - const { container } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) - const row = container.firstElementChild! - const padding = document.createElement('div') - row.append(padding) - - expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 120 })).toBe( - 'parent' - ) - expect(getWorktreeLineageDropTargetId({ container, target: row, pointerY: 180 })).toBe('parent') - expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 201 })).toBeNull() - }) - it.each([true, false])( 'keeps descendants out of the ancestor hit zone (inline content: %s)', (insideParentContent) => { @@ -119,138 +34,8 @@ describe('getWorktreeLineageDropTargetId', () => { expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBe('parent') } ) - - it('does not use a descendant content element for a row without its own content', () => { - const { container, target } = makeTarget({ worktreeId: 'child', top: 100, bottom: 200 }) - const parentRow = document.createElement('div') - parentRow.setAttribute('data-worktree-drag-id', 'parent') - parentRow.append(container.firstElementChild!) - container.append(parentRow) - - expect( - getWorktreeLineageDropTargetId({ container, target: parentRow, pointerY: 150 }) - ).toBeNull() - expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('child') - }) }) -describe('getReorderedWorktreeIdsToUnnest', () => { - it('clears parents only for directly dragged nested cards', () => { - const parent = makeWorktree('parent') - const child = makeWorktree('child') - const root = makeWorktree('root') - const grandchild = makeWorktree('grandchild') - const lineageById = { - [child.id]: makeLineage(child, parent), - [grandchild.id]: makeLineage(grandchild, child) - } - const worktreeMap = new Map([parent, child, root, grandchild].map((item) => [item.id, item])) - - expect( - getReorderedWorktreeIdsToUnnest({ - draggedIds: ['child', 'child', 'root', 'grandchild'], - sourceGroupIds: ['child', 'root', 'grandchild'], - lineageById, - worktreeMap, - cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) - }) - ).toEqual(['child', 'grandchild']) - }) - - it('does not clear selected nested cards outside the reordered source group', () => { - const parent = makeWorktree('parent') - const sourceChild = makeWorktree('source-child') - const otherChild = makeWorktree('other-child') - const lineageById = { - [sourceChild.id]: makeLineage(sourceChild, parent), - [otherChild.id]: makeLineage(otherChild, parent) - } - const worktreeMap = new Map([parent, sourceChild, otherChild].map((item) => [item.id, item])) - - expect( - getReorderedWorktreeIdsToUnnest({ - draggedIds: ['source-child', 'other-child'], - sourceGroupIds: ['source-child'], - lineageById, - worktreeMap, - cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) - }) - ).toEqual(['source-child']) - }) - - it('clears an exact inline-only legacy parent', () => { - const parent = makeWorktree('parent') - const child = makeWorktree('child') - const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree - const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item])) - - expect( - getReorderedWorktreeIdsToUnnest({ - draggedIds: [child.id], - sourceGroupIds: [child.id], - lineageById: {}, - worktreeMap, - cyclicLineageIds: getCyclicProjectedWorktreeLineageIds({}, worktreeMap) - }) - ).toEqual([child.id]) - }) - - it('does not fall back to inline lineage when the side-map has a stale child entry', () => { - const parent = makeWorktree('parent') - const child = makeWorktree('child') - const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree - const lineageById = { - [child.id]: { ...makeLineage(child, parent), parentWorktreeInstanceId: 'stale-parent' } - } - const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item])) - - expect( - getReorderedWorktreeIdsToUnnest({ - draggedIds: [child.id], - sourceGroupIds: [child.id], - lineageById, - worktreeMap, - cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) - }) - ).toEqual([]) - }) -}) - -function makeWorktree(id: string): Worktree { - return { - id, - instanceId: `${id}-instance`, - repoId: 'repo-1', - path: `/worktrees/${id}`, - head: 'abc123', - branch: id, - isBare: false, - isMainWorktree: false, - displayName: id, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1 - } -} - -function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage { - return { - worktreeId: child.id, - worktreeInstanceId: child.instanceId ?? '', - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId ?? '', - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } -} - function makeTarget(args: { worktreeId: string top: number diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order.test.ts index bb1ac2483a7..202014aecdd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import { buildWorktreeDragPreviewOffsets } from './worktree-drag-preview-offsets' + import { - buildManualOrderUpdatesForGroupDrop, buildManualOrderUpdatesForVisibleGroups, - expandDraggedWorktreeIdsForVisibleLineage, - moveWorktreeIdsWithinGroup, - shouldWriteManualOrderForGroupDrop + moveWorktreeIdsWithinGroup } from './worktree-manual-order' import { buildSparseManualOrderUpdates } from './worktree-manual-order-ranks' @@ -29,35 +26,6 @@ describe('buildSparseManualOrderUpdates durable migration', () => { ]) }) - it('keeps sparse updates after every known row has a durable rank', () => { - const result = buildSparseManualOrderUpdates({ - orderedIds: ['a', 'c', 'b'], - movedIds: ['b'], - allWorktreeIds: ['a', 'b', 'hidden', 'c'], - rankByWorktreeId: new Map([ - ['a', 4000], - ['b', 3000], - ['hidden', 2000], - ['c', 1000] - ]), - now: 10_000 - }) - - expect([...result]).toEqual([['b', { manualOrder: 0 }]]) - }) - - it('never drops known rows when a stale visible sequence contains unknowns or duplicates', () => { - const result = buildSparseManualOrderUpdates({ - orderedIds: ['unknown', 'b', 'b', 'a'], - movedIds: ['b'], - allWorktreeIds: ['a', 'b', 'hidden', 'c', 'c'], - rankByWorktreeId: new Map(), - now: 10_000 - }) - - expect([...result.keys()]).toEqual(['b', 'a', 'hidden', 'c', 'unknown']) - }) - it('preserves hidden rows when dense ranks require a full reindex', () => { const result = buildSparseManualOrderUpdates({ orderedIds: ['a', 'c', 'b'], @@ -74,109 +42,9 @@ describe('buildSparseManualOrderUpdates durable migration', () => { expect([...result.keys()]).toEqual(['a', 'hidden', 'c', 'b']) }) - - it('preserves hidden rows when a stale neighbor requires a full reindex', () => { - const result = buildSparseManualOrderUpdates({ - orderedIds: ['stale', 'b', 'a'], - movedIds: ['b'], - allWorktreeIds: ['a', 'hidden', 'b'], - rankByWorktreeId: new Map([ - ['a', 3000], - ['hidden', 2000], - ['b', 1000] - ]), - now: 10_000 - }) - - expect([...result.keys()]).toEqual(['b', 'hidden', 'a', 'stale']) - }) -}) - -describe('expandDraggedWorktreeIdsForVisibleLineage', () => { - it('expands an expanded lineage parent to its visible descendants for reordering', () => { - expect( - expandDraggedWorktreeIdsForVisibleLineage( - [ - { worktreeId: 'parent', depth: 0 }, - { worktreeId: 'child', depth: 1 }, - { worktreeId: 'grandchild', depth: 2 }, - { worktreeId: 'sibling', depth: 0 } - ], - ['parent'] - ) - ).toEqual(['parent', 'child', 'grandchild']) - }) - - it('keeps unrelated selected rows in visual order', () => { - expect( - expandDraggedWorktreeIdsForVisibleLineage( - [ - { worktreeId: 'a', depth: 0 }, - { worktreeId: 'parent', depth: 0 }, - { worktreeId: 'child', depth: 1 }, - { worktreeId: 'z', depth: 0 } - ], - ['z', 'parent'] - ) - ).toEqual(['parent', 'child', 'z']) - }) - - it('keeps ancestor coverage when a nested descendant is selected too', () => { - expect( - expandDraggedWorktreeIdsForVisibleLineage( - [ - { worktreeId: 'parent', depth: 0 }, - { worktreeId: 'child', depth: 2 }, - { worktreeId: 'uncleish', depth: 1 }, - { worktreeId: 'sibling', depth: 0 } - ], - ['parent', 'child'] - ) - ).toEqual(['parent', 'child', 'uncleish']) - }) - - it('restarts coverage for a later selected sibling after the previous run ends', () => { - expect( - expandDraggedWorktreeIdsForVisibleLineage( - [ - { worktreeId: 'first', depth: 0 }, - { worktreeId: 'first-child', depth: 1 }, - { worktreeId: 'second', depth: 0 }, - { worktreeId: 'second-child', depth: 1 } - ], - ['first', 'second'] - ) - ).toEqual(['first', 'first-child', 'second', 'second-child']) - }) - - it('emits each absent selected id once', () => { - expect( - expandDraggedWorktreeIdsForVisibleLineage( - [{ worktreeId: 'row', depth: 0 }], - ['gone', 'row', 'gone'] - ) - ).toEqual(['row', 'gone']) - }) }) describe('moveWorktreeIdsWithinGroup', () => { - it('moves a worktree down using the original drop index', () => { - expect(moveWorktreeIdsWithinGroup(['a', 'b', 'c', 'd'], ['b'], 4)).toEqual(['a', 'c', 'd', 'b']) - }) - - it('moves a worktree up', () => { - expect(moveWorktreeIdsWithinGroup(['a', 'b', 'c'], ['c'], 0)).toEqual(['c', 'a', 'b']) - }) - - it('preserves selected order for multi-drag batches', () => { - expect(moveWorktreeIdsWithinGroup(['a', 'b', 'c', 'd'], ['c', 'b'], 4)).toEqual([ - 'a', - 'd', - 'b', - 'c' - ]) - }) - it('moves a very large selected batch without overflowing argument limits', () => { const ids = Array.from({ length: 130_000 }, (_, index) => `wt-${index}`) @@ -188,247 +56,7 @@ describe('moveWorktreeIdsWithinGroup', () => { }) }) -describe('buildWorktreeDragPreviewOffsets', () => { - it('slides intervening rows up while dragging a row down', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b', 'c', 'd'], - draggedIds: ['b'], - dropIndex: 4, - 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 } - ] - }) - - expect(Array.from(offsets)).toEqual([ - ['c', -56], - ['d', -56] - ]) - }) - - it('keeps downward offsets stable after virtualization unmounts the leading rows', () => { - const { offsets, placeholderTop } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b', 'c', 'd', 'e', 'f'], - draggedIds: ['a'], - draggingWorktreeId: 'a', - draggedPreviewHeight: 50, - dropIndex: 6, - 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 } - ] - }) - - expect(Array.from(offsets)).toEqual([ - ['d', -56], - ['e', -56], - ['f', -56] - ]) - expect(placeholderTop).toBe(280) - }) - - it('slides intervening rows down while dragging a row up', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b', 'c'], - draggedIds: ['c'], - dropIndex: 0, - rects: [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 40 }, - { worktreeId: 'b', groupIndex: 1, top: 46, bottom: 86 }, - { worktreeId: 'c', groupIndex: 2, top: 92, bottom: 132 } - ] - }) - - expect(Array.from(offsets)).toEqual([ - ['a', 46], - ['b', 46] - ]) - }) - - it('returns no preview offsets for a no-op hover', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b'], - draggedIds: ['a'], - dropIndex: 1, - rects: [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 40 }, - { worktreeId: 'b', groupIndex: 1, top: 46, bottom: 86 } - ] - }) - - expect(offsets.size).toBe(0) - }) - - it('uses the dragged unit height when previewing variable-height rows', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['parent', 'sibling'], - draggedIds: ['parent'], - dropIndex: 2, - rects: [ - { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 300 }, - { worktreeId: 'sibling', groupIndex: 1, top: 308, bottom: 408 } - ] - }) - - expect(Array.from(offsets)).toEqual([['sibling', -308]]) - }) - - it('uses the dragged unit height when previewing a short row above a tall row', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['parent', 'sibling'], - draggedIds: ['sibling'], - dropIndex: 0, - rects: [ - { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 300 }, - { worktreeId: 'sibling', groupIndex: 1, top: 308, bottom: 408 } - ] - }) - - expect(Array.from(offsets)).toEqual([['parent', 108]]) - }) - - it('reserves one card-height slot while previewing a multi-select batch', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b', 'c', 'd', 'e'], - draggedIds: ['b', 'c', 'd'], - draggingWorktreeId: 'b', - dropIndex: 5, - 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 } - ] - }) - - expect(Array.from(offsets)).toEqual([ - ['c', -56], - ['d', -56], - ['e', -56] - ]) - }) - - it('uses the grabbed selected card as the one preview placeholder', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b', 'c', 'd', 'e'], - draggedIds: ['b', 'c', 'd'], - draggingWorktreeId: 'd', - dropIndex: 5, - 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 } - ] - }) - - expect(Array.from(offsets)).toEqual([['e', -56]]) - }) - - it('returns no preview offsets for a no-op multi-select hover', () => { - const { offsets } = buildWorktreeDragPreviewOffsets({ - groupIds: ['a', 'b', 'c', 'd', 'e'], - draggedIds: ['b', 'c', 'd'], - draggingWorktreeId: 'b', - dropIndex: 4, - 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 } - ] - }) - - expect(offsets.size).toBe(0) - }) -}) - describe('buildManualOrderUpdatesForVisibleGroups', () => { - it('updates every visible workspace order while only moving the source group', () => { - const result = buildManualOrderUpdatesForVisibleGroups({ - groups: [ - { key: 'workspace-status:todo', worktreeIds: ['todo-a', 'todo-b'] }, - { key: 'workspace-status:done', worktreeIds: ['done-a', 'done-b'] } - ], - sourceGroupKey: 'workspace-status:todo', - draggedIds: ['todo-b'], - dropIndex: 0, - allWorktreeIds: ['todo-a', 'todo-b', 'done-a', 'done-b'], - now: 10_000 - }) - - expect(result.changed).toBe(true) - expect(result.orderedIds).toEqual(['todo-b', 'todo-a', 'done-a', 'done-b']) - expect(Array.from(result.updates)).toEqual([ - ['todo-b', { manualOrder: 10_000 }], - ['todo-a', { manualOrder: 9000 }], - ['done-a', { manualOrder: 8000 }], - ['done-b', { manualOrder: 7000 }] - ]) - }) - - it('returns no updates for a no-op drop', () => { - const result = buildManualOrderUpdatesForVisibleGroups({ - groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], - sourceGroupKey: 'repo:one', - draggedIds: ['a'], - dropIndex: 1, - allWorktreeIds: ['a', 'b'], - now: 10_000 - }) - - expect(result.changed).toBe(false) - expect(result.updates.size).toBe(0) - }) - - it('only updates moved rows when current ranks leave room', () => { - const result = buildManualOrderUpdatesForVisibleGroups({ - groups: [{ key: 'repo:one', worktreeIds: ['a', 'b', 'c', 'd'] }], - sourceGroupKey: 'repo:one', - draggedIds: ['b'], - dropIndex: 4, - allWorktreeIds: ['a', 'b', 'c', 'd'], - now: 10_000, - rankByWorktreeId: new Map([ - ['a', 4000], - ['b', 3000], - ['c', 2000], - ['d', 1000] - ]) - }) - - expect(result.orderedIds).toEqual(['a', 'c', 'd', 'b']) - expect(Array.from(result.updates)).toEqual([['b', { manualOrder: 0 }]]) - }) - - it('moves an expanded lineage cluster as one ranked unit', () => { - const result = buildManualOrderUpdatesForVisibleGroups({ - groups: [{ key: 'all', worktreeIds: ['parent', 'child', 'other'] }], - sourceGroupKey: 'all', - draggedIds: ['parent', 'child'], - dropIndex: 3, - allWorktreeIds: ['parent', 'child', 'other'], - now: 10_000, - rankByWorktreeId: new Map([ - ['parent', 3000], - ['child', 2000], - ['other', 1000] - ]) - }) - - expect(result.orderedIds).toEqual(['other', 'parent', 'child']) - expect(Array.from(result.updates)).toEqual([ - ['parent', { manualOrder: 0 }], - ['child', { manualOrder: -1000 }] - ]) - }) - it('reorders a very large visible group without overflowing argument limits', () => { const ids = Array.from({ length: 130_000 }, (_, index) => `wt-${index}`) const rankByWorktreeId = new Map( @@ -452,113 +80,3 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => { expect(Array.from(result.updates)).toEqual([['wt-0', { manualOrder: 0 }]]) }) }) - -describe('buildManualOrderUpdatesForGroupDrop', () => { - it('moves a selected batch across groups and stamps visible manual order', () => { - const result = buildManualOrderUpdatesForGroupDrop({ - groups: [ - { key: 'todo', worktreeIds: ['todo-a', 'todo-b'] }, - { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } - ], - targetGroupKey: 'doing', - draggedIds: ['todo-b'], - dropIndex: 1, - allWorktreeIds: ['todo-a', 'todo-b', 'doing-a', 'doing-b'], - now: 10_000 - }) - - expect(result.changed).toBe(true) - expect(result.orderedIds).toEqual(['todo-a', 'doing-a', 'todo-b', 'doing-b']) - expect(Array.from(result.updates)).toEqual([ - ['todo-a', { manualOrder: 10_000 }], - ['doing-a', { manualOrder: 9000 }], - ['todo-b', { manualOrder: 8000 }], - ['doing-b', { manualOrder: 7000 }] - ]) - }) - - it('keeps visual order for multi-select batches spanning groups', () => { - const result = buildManualOrderUpdatesForGroupDrop({ - groups: [ - { key: 'todo', worktreeIds: ['a', 'b'] }, - { key: 'doing', worktreeIds: ['c', 'd'] } - ], - targetGroupKey: 'doing', - draggedIds: ['d', 'b'], - dropIndex: 0, - allWorktreeIds: ['a', 'b', 'c', 'd'], - now: 10_000 - }) - - expect(result.orderedIds).toEqual(['a', 'b', 'd', 'c']) - }) - - it('returns no updates for a no-op same-group drop', () => { - const result = buildManualOrderUpdatesForGroupDrop({ - groups: [{ key: 'doing', worktreeIds: ['a', 'b'] }], - targetGroupKey: 'doing', - draggedIds: ['a'], - dropIndex: 1, - allWorktreeIds: ['a', 'b'], - now: 10_000 - }) - - expect(result.changed).toBe(false) - expect(result.updates.size).toBe(0) - }) - - it('uses sparse moved-row ranks for cross-lane manual drops', () => { - const result = buildManualOrderUpdatesForGroupDrop({ - groups: [ - { key: 'todo', worktreeIds: ['todo-a', 'todo-b'] }, - { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } - ], - targetGroupKey: 'doing', - draggedIds: ['todo-b'], - dropIndex: 1, - allWorktreeIds: ['todo-a', 'todo-b', 'doing-a', 'doing-b'], - now: 10_000, - rankByWorktreeId: new Map([ - ['todo-a', 4000], - ['todo-b', 3000], - ['doing-a', 2000], - ['doing-b', 1000] - ]) - }) - - expect(result.orderedIds).toEqual(['todo-a', 'doing-a', 'todo-b', 'doing-b']) - expect(Array.from(result.updates)).toEqual([['todo-b', { manualOrder: 1500 }]]) - }) -}) - -describe('shouldWriteManualOrderForGroupDrop', () => { - it('writes order for any lane drop while Manual sort is active', () => { - expect( - shouldWriteManualOrderForGroupDrop({ - sortBy: 'manual', - sourceGroupKeys: ['todo'], - targetGroupKey: 'doing' - }) - ).toBe(true) - }) - - it('writes order for same-lane drops outside Manual sort', () => { - expect( - shouldWriteManualOrderForGroupDrop({ - sortBy: 'recent', - sourceGroupKeys: ['doing', 'doing'], - targetGroupKey: 'doing' - }) - ).toBe(true) - }) - - it('keeps cross-lane drops status-only outside Manual sort', () => { - expect( - shouldWriteManualOrderForGroupDrop({ - sortBy: 'recent', - sourceGroupKeys: ['todo', 'doing'], - targetGroupKey: 'doing' - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-autoscroll.test.ts deleted file mode 100644 index 26e8d1d7aba..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-autoscroll.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getWorktreeSidebarDragAutoscroll, - getWorktreeSidebarBoundaryDrop, - getWorktreeSidebarDragRectsForGroup, - refreshWorktreeSidebarDragSession, - type WorktreeSidebarDragRect, - type WorktreeSidebarDragSession -} from './worktree-sidebar-drag-autoscroll' - -const CONTAINER_RECT = { - left: 10, - right: 210, - top: 100, - bottom: 500 -} - -const SESSION: WorktreeSidebarDragSession = { - draggingWorktreeId: 'b', - sourceGroupKey: 'repo:one', - draggedIds: ['b'], - draggedCopies: [], - reorderDraggedIds: ['b'], - reorderDraggedKeys: ['b'], - reorderUnitDraggedKeys: ['b'], - rects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }], - grab: null, - anchor: null -} - -describe('getWorktreeSidebarDragAutoscroll', () => { - it('scrolls up near the top edge', () => { - expect(getAutoscroll({ clientX: 80, clientY: 112 })?.scrollTop).toBeCloseTo(187.93, 2) - }) - - it('scrolls down near the bottom edge', () => { - expect(getAutoscroll({ clientX: 80, clientY: 488 })?.scrollTop).toBeCloseTo(212.07, 2) - }) - - it('does nothing away from the vertical edge zones', () => { - expect(getAutoscroll({ clientX: 80, clientY: 300 })).toBeNull() - }) - - it('does nothing when the pointer is outside horizontally', () => { - expect(getAutoscroll({ clientX: 4, clientY: 488 })).toBeNull() - }) - - it('does not write past scroll bounds', () => { - expect(getAutoscroll({ clientX: 80, clientY: 100 }, { scrollTop: 0 })).toBeNull() - expect(getAutoscroll({ clientX: 80, clientY: 500 }, { scrollTop: 600 })).toBeNull() - }) - - it('allows capped scrolling slightly beyond the vertical edge', () => { - expect(getAutoscroll({ clientX: 80, clientY: 530 })?.scrollTop).toBeCloseTo(215.36, 2) - expect(getAutoscroll({ clientX: 80, clientY: 560 })).toBeNull() - }) - - it('scales by elapsed frame time and clamps delayed frames', () => { - const normal = getAutoscroll({ clientX: 80, clientY: 500 }) - const delayed = getAutoscroll({ clientX: 80, clientY: 500 }, { elapsedMs: 200 }) - - expect(normal?.scrollTop).toBeCloseTo(215.36, 2) - expect(delayed?.scrollTop).toBeCloseTo(230.72, 2) - }) -}) - -describe('getWorktreeSidebarBoundaryDrop', () => { - it('clamps near the group start instead of clearing the edge preview', () => { - expect( - getWorktreeSidebarBoundaryDrop({ - localY: 70, - firstRect: { worktreeId: 'a', groupIndex: 0, top: 100, bottom: 140 }, - lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 }, - sourceGroupSize: 3 - }) - ).toEqual({ kind: 'drop', dropIndex: 0, indicatorY: 97 }) - }) - - it('clamps near the group end instead of clearing the edge preview', () => { - expect( - getWorktreeSidebarBoundaryDrop({ - localY: 270, - firstRect: { worktreeId: 'a', groupIndex: 0, top: 100, bottom: 140 }, - lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 }, - sourceGroupSize: 3 - }) - ).toEqual({ kind: 'drop', dropIndex: 3, indicatorY: 243 }) - }) - - it('still rejects gaps that are not the real group edge', () => { - for (const localY of [70, 270]) { - expect( - getWorktreeSidebarBoundaryDrop({ - localY, - firstRect: { worktreeId: 'b', groupIndex: 1, top: 100, bottom: 140 }, - lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 }, - sourceGroupSize: 4 - }) - ).toEqual({ kind: 'outside' }) - } - }) - - it('keeps normal in-range hover handling unchanged', () => { - expect( - getWorktreeSidebarBoundaryDrop({ - localY: 160, - firstRect: { worktreeId: 'a', groupIndex: 0, top: 100, bottom: 140 }, - lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 }, - sourceGroupSize: 3 - }) - ).toEqual({ kind: 'inside' }) - }) -}) - -describe('getWorktreeSidebarDragRectsForGroup', () => { - it('refreshes mounted rects for the source group only', () => { - const container = makeContainer([ - makeDragElement('a', 'repo:one', '0', 140, 180), - makeDragElement('x', 'repo:two', '0', 80, 120), - makeDragElement('b', 'repo:one', '1', 90, 130) - ]) - - expect(getWorktreeSidebarDragRectsForGroup(container, 'repo:one')).toEqual([ - { worktreeId: 'b', groupIndex: 1, top: 40, bottom: 80 }, - { worktreeId: 'a', groupIndex: 0, top: 90, bottom: 130 } - ]) - }) - - it('anchors rects to virtual row slots so animated transforms do not perturb hit testing', () => { - const container = makeContainer([ - makeDragElement('a', 'repo:one', '0', 320, 360, { start: 240, top: 310 }), - makeDragElement('b', 'repo:one', '1', 380, 420, { start: 298, top: 368 }) - ]) - - expect(getWorktreeSidebarDragRectsForGroup(container, 'repo:one')).toEqual([ - { worktreeId: 'a', groupIndex: 0, top: 250, bottom: 290 }, - { worktreeId: 'b', groupIndex: 1, top: 310, bottom: 350 } - ]) - }) -}) - -describe('refreshWorktreeSidebarDragSession', () => { - it('keeps the dragged set stable while refreshing rects', () => { - const rects: WorktreeSidebarDragRect[] = [ - { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 40 }, - { worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 } - ] - - expect( - refreshWorktreeSidebarDragSession({ - session: SESSION, - groups: [{ key: 'repo:one', worktreeIds: ['a', 'b', 'child'] }], - unitGroups: [ - { - key: 'repo:one', - worktreeIds: ['a', 'b'], - units: [ - { worktreeId: 'a', worktreeIds: ['a'] }, - { worktreeId: 'b', worktreeIds: ['b', 'child'] } - ] - } - ], - rects - }) - ).toEqual({ ...SESSION, rects }) - }) - - it('clears when the source group is missing', () => { - expect( - refreshWorktreeSidebarDragSession({ - session: SESSION, - groups: [{ key: 'repo:two', worktreeIds: ['b'] }], - unitGroups: [{ key: 'repo:one', worktreeIds: ['b'], units: [] }], - rects: [] - }) - ).toBeNull() - }) - - it('clears when the dragged worktree or reordered unit disappears', () => { - expect( - refreshWorktreeSidebarDragSession({ - session: SESSION, - groups: [{ key: 'repo:one', worktreeIds: ['a'] }], - unitGroups: [{ key: 'repo:one', worktreeIds: ['b'], units: [] }], - rects: [] - }) - ).toBeNull() - expect( - refreshWorktreeSidebarDragSession({ - session: { ...SESSION, reorderUnitDraggedKeys: ['missing'] }, - groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], - unitGroups: [{ key: 'repo:one', worktreeIds: ['a'], units: [] }], - rects: [] - }) - ).toBeNull() - }) - - it('keeps a valid session when mounted rects are temporarily empty', () => { - expect( - refreshWorktreeSidebarDragSession({ - session: SESSION, - groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], - unitGroups: [{ key: 'repo:one', worktreeIds: ['a', 'b'], units: [] }], - rects: [] - }) - ).toEqual({ ...SESSION, rects: [] }) - }) - - it('keeps child-card reorder drags even when the child is not a top-level unit', () => { - const childSession: WorktreeSidebarDragSession = { - ...SESSION, - draggingWorktreeId: 'child', - draggedIds: ['child'], - reorderDraggedIds: ['child'], - reorderDraggedKeys: ['child'], - reorderUnitDraggedKeys: ['child'] - } - const rects: WorktreeSidebarDragRect[] = [ - { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 80 }, - { worktreeId: 'child', groupIndex: 1, top: 88, bottom: 128 }, - { worktreeId: 'sibling', groupIndex: 2, top: 136, bottom: 176 } - ] - - expect( - refreshWorktreeSidebarDragSession({ - session: childSession, - groups: [{ key: 'repo:one', worktreeIds: ['parent', 'child', 'sibling'] }], - unitGroups: [ - { - key: 'repo:one', - worktreeIds: ['parent', 'sibling'], - units: [ - { worktreeId: 'parent', worktreeIds: ['parent', 'child'] }, - { worktreeId: 'sibling', worktreeIds: ['sibling'] } - ] - } - ], - rects - }) - ).toEqual({ ...childSession, rects }) - }) -}) - -function makeContainer(elements: readonly ReturnType[]): HTMLElement { - return { - scrollTop: 50, - getBoundingClientRect: () => ({ top: 100 }), - querySelectorAll: () => elements - } as unknown as HTMLElement -} - -function getAutoscroll( - point: { clientX: number; clientY: number }, - overrides: Partial[0]> = {} -) { - return getWorktreeSidebarDragAutoscroll({ - point, - containerRect: CONTAINER_RECT, - scrollTop: 200, - scrollHeight: 1000, - clientHeight: 400, - elapsedMs: 16, - ...overrides - }) -} - -function makeDragElement( - worktreeId: string, - groupKey: string, - groupIndex: string, - top: number, - bottom: number, - virtualRow?: { start: number; top: number } -): HTMLElement { - const attributes = new Map([ - ['data-worktree-drag-id', worktreeId], - // Rects key slots by host identity; these fixtures reuse the id string as that key. - ['data-worktree-host-identity', worktreeId], - ['data-worktree-drag-group-key', groupKey], - ['data-worktree-drag-group-index', groupIndex] - ]) - const virtualRowElement = virtualRow - ? ({ - getAttribute: (name: string) => - name === 'data-worktree-virtual-row-start' ? String(virtualRow.start) : null, - getBoundingClientRect: () => ({ top: virtualRow.top }) - } as unknown as HTMLElement) - : null - return { - getAttribute: (name: string) => attributes.get(name) ?? null, - getBoundingClientRect: () => ({ top, bottom, height: bottom - top }), - closest: (selector: string) => - selector === '[data-worktree-virtual-row]' ? virtualRowElement : null - } as unknown as HTMLElement -} diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-geometry.test.ts b/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-geometry.test.ts deleted file mode 100644 index d640427d3b0..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-geometry.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { computeWorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' -import { - getWorktreeSidebarDragGrab, - getWorktreeSidebarDragReferenceY, - resolveWorktreeSidebarDropAnchorIndex, - shouldReevaluateWorktreeSidebarDropAnchor, - type WorktreeSidebarDropAnchor -} from './worktree-sidebar-drag-geometry' -import { - refreshWorktreeSidebarDragSession, - type WorktreeSidebarDragRect -} from './worktree-sidebar-drag-autoscroll' - -const GROUP_IDS = ['a', 'b', 'c', 'd', 'e'] -const CARD_HEIGHT = 116 -const ROW_GAP = 6 -// Why: a card with several agent rows expanded runs ~3.5x a collapsed one. -const EXPANDED_CARD_HEIGHT = 404 - -function layout(heightByWorktreeId: Readonly>): WorktreeSidebarDragRect[] { - let top = 0 - return GROUP_IDS.map((worktreeId, groupIndex) => { - const height = heightByWorktreeId[worktreeId] ?? CARD_HEIGHT - const rect = { worktreeId, groupIndex, top, bottom: top + height } - top += height + ROW_GAP - return rect - }) -} - -const COLLAPSED = layout({}) -const GRAB = { offsetY: CARD_HEIGHT / 2, height: CARD_HEIGHT } - -function previewAt(args: { - pointerY: number - rects: readonly WorktreeSidebarDragRect[] - anchor?: WorktreeSidebarDropAnchor | null - draggingWorktreeId?: string - grab?: { offsetY: number; height: number } | null -}) { - return computeWorktreeSidebarDropPreview({ - pointerY: args.pointerY, - containerTop: 0, - scrollTop: 0, - rects: args.rects, - groupIds: GROUP_IDS, - draggedIds: [args.draggingWorktreeId ?? 'a'], - draggingWorktreeId: args.draggingWorktreeId ?? 'a', - grab: args.grab === undefined ? GRAB : args.grab, - anchor: args.anchor - }) -} - -/** Replay a stationary-pointer drag through card expansion, holding decisions as the live loop does. */ -function replayStillPointer(args: { - pointerY: number - frames: readonly (readonly WorktreeSidebarDragRect[])[] -}): { dropIndexes: number[]; indicatorYs: number[] } { - let anchor: WorktreeSidebarDropAnchor | null = null - const dropIndexes: number[] = [] - const indicatorYs: number[] = [] - for (const rects of args.frames) { - const held = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor, - pointerY: args.pointerY, - scrollTop: 0 - }) - ? null - : anchor - const preview = previewAt({ pointerY: args.pointerY, rects, anchor: held })! - anchor = { beforeWorktreeId: preview.dropAnchorId, pointerY: args.pointerY, scrollTop: 0 } - dropIndexes.push(preview.dropIndex) - indicatorYs.push(preview.dropIndicatorY) - } - return { dropIndexes, indicatorYs } -} - -describe('worktree sidebar drag geometry under mid-drag card growth', () => { - it('keeps the drop target fixed while a card expands under a still pointer', () => { - const pointerY = 250 - const grown = layout({ b: EXPANDED_CARD_HEIGHT }) - - const unheld = previewAt({ pointerY, rects: grown })!.dropIndex - const { dropIndexes } = replayStillPointer({ pointerY, frames: [COLLAPSED, grown] }) - - // Re-deciding from the grown layout would move the target with zero input. - expect(unheld).not.toBe(dropIndexes[0]) - expect(dropIndexes[1]).toBe(dropIndexes[0]) - }) - - it('never lets a growing card change the drop target across a whole expansion', () => { - const frames = Array.from({ length: 12 }, (_, frame) => - layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 }) - ) - - for (const pointerY of [150, 250, 350, 450, 550]) { - const unheld = frames.map((rects) => previewAt({ pointerY, rects })!.dropIndex) - const { dropIndexes } = replayStillPointer({ pointerY, frames }) - - expect(new Set(dropIndexes).size).toBe(1) - // The scenario has to be one that actually moves without the hold. - if (pointerY !== 150) { - expect(new Set(unheld).size).toBeGreaterThan(1) - } - } - }) - - it('slides the indicator with the gap it marks while geometry is held', () => { - const frames = Array.from({ length: 12 }, (_, frame) => - layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 }) - ) - const { dropIndexes, indicatorYs } = replayStillPointer({ pointerY: 350, frames }) - - expect(new Set(dropIndexes).size).toBe(1) - // Held decision, live rendering: the line tracks the growing card, never freezes. - expect(indicatorYs.at(-1)!).toBeGreaterThan(indicatorYs[0]!) - expect(new Set(indicatorYs).size).toBe(frames.length) - }) - - it('still tracks the pointer normally once it moves again', () => { - const grown = layout({ b: EXPANDED_CARD_HEIGHT }) - - expect(previewAt({ pointerY: 100, rects: grown })!.dropIndex).toBeLessThan( - previewAt({ pointerY: 800, rects: grown })!.dropIndex - ) - }) - - it('re-evaluates on real pointer or scroll movement but not on jitter', () => { - const anchor: WorktreeSidebarDropAnchor = { - beforeWorktreeId: 'c', - pointerY: 250, - scrollTop: 40 - } - - expect( - shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250, scrollTop: 40 }) - ).toBe(false) - expect( - shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250.2, scrollTop: 40 }) - ).toBe(false) - expect( - shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 254, scrollTop: 40 }) - ).toBe(true) - expect( - shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250, scrollTop: 88 }) - ).toBe(true) - expect( - shouldReevaluateWorktreeSidebarDropAnchor({ anchor: null, pointerY: 250, scrollTop: 40 }) - ).toBe(true) - }) - - it('falls back to a fresh decision when the anchored card disappears mid-drag', () => { - const anchor: WorktreeSidebarDropAnchor = { - beforeWorktreeId: 'gone', - pointerY: 250, - scrollTop: 0 - } - - expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, groupIds: GROUP_IDS })).toBeNull() - expect( - resolveWorktreeSidebarDropAnchorIndex({ - anchor: { beforeWorktreeId: 'c', pointerY: 0, scrollTop: 0 }, - groupIds: GROUP_IDS - }) - ).toBe(2) - // A null anchor id means end-of-group, which survives any row count change. - expect( - resolveWorktreeSidebarDropAnchorIndex({ - anchor: { beforeWorktreeId: null, pointerY: 0, scrollTop: 0 }, - groupIds: GROUP_IDS - }) - ).toBe(GROUP_IDS.length) - }) - - it('keeps one live coordinate space across a session refresh', () => { - const grown = layout({ b: EXPANDED_CARD_HEIGHT }) - const refreshed = refreshWorktreeSidebarDragSession({ - session: { - draggingWorktreeId: 'a', - sourceGroupKey: 'repo:one', - draggedIds: ['a'], - draggedCopies: [], - reorderDraggedIds: ['a'], - reorderDraggedKeys: ['a'], - reorderUnitDraggedKeys: ['a'], - rects: COLLAPSED, - grab: GRAB, - anchor: null - }, - groups: [{ key: 'repo:one', worktreeIds: GROUP_IDS }], - unitGroups: [ - { - key: 'repo:one', - worktreeIds: GROUP_IDS, - units: GROUP_IDS.map((worktreeId) => ({ worktreeId, worktreeIds: [worktreeId] })) - } - ], - rects: grown - }) - - expect(refreshed?.rects).toBe(grown) - expect(refreshed?.grab).toBe(GRAB) - }) -}) - -describe('grab-relative hit testing', () => { - it('projects the dragged card from the pointer instead of using the bare pointer', () => { - const activeRect = { worktreeId: 'a', groupIndex: 0, top: 0, bottom: CARD_HEIGHT } - - // Grabbed at the very top edge: the card sits below the pointer. - expect( - getWorktreeSidebarDragReferenceY({ - localY: 300, - grab: { offsetY: 0, height: CARD_HEIGHT }, - activeRect - }) - ).toBe(300 + CARD_HEIGHT / 2) - // Grabbed at the bottom edge: the card sits above the pointer. - expect( - getWorktreeSidebarDragReferenceY({ - localY: 300, - grab: { offsetY: CARD_HEIGHT, height: CARD_HEIGHT }, - activeRect - }) - ).toBe(300 - CARD_HEIGHT / 2) - // No grab (native HTML5 drag) degrades to the raw pointer. - expect(getWorktreeSidebarDragReferenceY({ localY: 300, grab: null, activeRect })).toBe(300) - }) - - it('clamps a grab offset that lands outside the card', () => { - expect(getWorktreeSidebarDragGrab({ offsetY: -40, height: CARD_HEIGHT })).toEqual({ - offsetY: 0, - height: CARD_HEIGHT - }) - expect(getWorktreeSidebarDragGrab({ offsetY: 900, height: CARD_HEIGHT })).toEqual({ - offsetY: CARD_HEIGHT, - height: CARD_HEIGHT - }) - expect(getWorktreeSidebarDragGrab({ offsetY: 10, height: 0 })).toBeNull() - expect(getWorktreeSidebarDragGrab({ offsetY: Number.NaN, height: CARD_HEIGHT })).toBeNull() - }) -})