diff --git a/packages/treebeard/src/components/RepoDashboard.test.tsx b/packages/treebeard/src/components/RepoDashboard.test.tsx index 5aa3d66..b7401f2 100644 --- a/packages/treebeard/src/components/RepoDashboard.test.tsx +++ b/packages/treebeard/src/components/RepoDashboard.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RepoDashboard } from './RepoDashboard' import { renderWithMantine } from '../test/render' @@ -7,6 +7,7 @@ import type { ReactNode } from 'react' const useWorktreesMock = vi.fn() const useCollapsedMock = vi.fn() +const useWorktreeStatusMock = vi.fn() vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: ReactNode }) =>
{children}
, @@ -54,12 +55,34 @@ vi.mock('../hooks/useCollapsed', () => ({ useCollapsed: () => useCollapsedMock() })) +vi.mock('../hooks/useWorktreeStatus', () => ({ + useWorktreeStatus: (worktreePath: string, pollIntervalSec: number, refreshKey?: number) => + useWorktreeStatusMock(worktreePath, pollIntervalSec, refreshKey) +})) + vi.mock('../hooks/useHomedir', () => ({ useHomedir: () => ({ shortenPath: (value: string) => value }) })) +interface DirtyBadgeProps { + worktreePath?: string +} + +vi.mock('./DirtyBadge', () => ({ + DirtyBadge: ({ worktreePath }: DirtyBadgeProps) =>
{worktreePath}
+})) + +interface LaunchButtonsProps { + worktreePath: string + defaultIde: string +} + +vi.mock('./LaunchButtons', () => ({ + LaunchButtons: ({ worktreePath, defaultIde }: LaunchButtonsProps) =>
{`${defaultIde}:${worktreePath}`}
+})) + interface WorktreeCardProps { worktree: { branch: string } } @@ -76,10 +99,17 @@ describe('RepoDashboard', () => { beforeEach(() => { useCollapsedMock.mockReset() useWorktreesMock.mockReset() + useWorktreeStatusMock.mockReset() useCollapsedMock.mockReturnValue({ collapsed: new Set(), toggle: vi.fn() }) + useWorktreeStatusMock.mockReturnValue({ + status: null, + loading: false, + refresh: vi.fn() + }) useWorktreesMock.mockReturnValue({ worktrees: [], loading: false, + loaded: true, error: null, deleteError: null, deletingPaths: new Set(), @@ -121,6 +151,7 @@ describe('RepoDashboard', () => { { path: '/repo/wt/feat', branch: 'feat/testing', head: 'def', isMain: false } ], loading: false, + loaded: true, error: null, deleteError: null, deletingPaths: new Set(), @@ -169,4 +200,188 @@ describe('RepoDashboard', () => { expect(screen.queryByText('treebeard')).toBeNull() }) + + it('renders active repositories before inactive repositories without showing main worktrees by default', async () => { + const repos: RepoConfig[] = [ + { id: 'repo-1', name: 'repo-one', path: '/repo-one' }, + { id: 'repo-2', name: 'repo-two', path: '/repo-two' }, + { id: 'repo-3', name: 'repo-three', path: '/repo-three' }, + { id: 'repo-4', name: 'repo-four', path: '/repo-four' } + ] + + useWorktreesMock.mockImplementation((repoPath: string) => ({ + worktrees: repoPath === '/repo-one' + ? [{ path: '/repo-one/main', branch: 'main', head: 'abc', isMain: true }] + : repoPath === '/repo-two' + ? [ + { path: '/repo-two/main', branch: 'main', head: 'abc', isMain: true }, + { path: '/repo-two/feature', branch: 'feat/two', head: 'def', isMain: false } + ] + : repoPath === '/repo-three' + ? [ + { path: '/repo-three/main', branch: 'main', head: 'abc', isMain: true }, + { path: '/repo-three/feature', branch: 'feat/three', head: 'def', isMain: false } + ] + : [{ path: '/repo-four/main', branch: 'main', head: 'abc', isMain: true }], + loading: false, + loaded: true, + error: null, + deleteError: null, + deletingPaths: new Set(), + startDelete: vi.fn(), + clearDeleteError: vi.fn(), + settingUpPaths: new Set(), + setupError: null, + startSetup: vi.fn(), + clearSetupError: vi.fn(), + refresh: vi.fn() + })) + + renderWithMantine( + {}} + isDraggingJira={false} + overRepoId={null} + jiraDropTargets={{}} + onJiraDropBranchClear={() => {}} + /> + ) + + await waitFor(() => { + const names = screen.getAllByRole('heading', { level: 4 }).map((heading) => heading.textContent) + expect(names).toEqual(['repo-two', 'repo-three', 'repo-one', 'repo-four']) + }) + + expect(screen.getAllByTestId('worktree-card').map((card) => card.textContent)).toEqual(['feat/two', 'feat/three']) + }) + + it('shows main status and action controls for inactive repositories without rendering a main card body', () => { + const repos: RepoConfig[] = [{ id: 'repo-1', name: 'treebeard', path: '/repo' }] + + useWorktreesMock.mockReturnValue({ + worktrees: [ + { path: '/repo/wt/main', branch: 'main', head: 'abc', isMain: true } + ], + loading: false, + loaded: true, + error: null, + deleteError: null, + deletingPaths: new Set(), + startDelete: vi.fn(), + clearDeleteError: vi.fn(), + settingUpPaths: new Set(), + setupError: null, + startSetup: vi.fn(), + clearSetupError: vi.fn(), + refresh: vi.fn() + }) + + renderWithMantine( + {}} + isDraggingJira={false} + overRepoId={null} + jiraDropTargets={{}} + onJiraDropBranchClear={() => {}} + /> + ) + + expect(screen.queryAllByTestId('worktree-card')).toEqual([]) + expect(screen.getByTestId('main-status').textContent).toBe('/repo/wt/main') + expect(screen.getByTestId('main-actions').textContent).toBe('vscode:/repo/wt/main') + expect(useWorktreeStatusMock).toHaveBeenCalledWith('/repo/wt/main', 60, 0) + }) + + it('keeps main status and action controls visible for collapsed active repositories', () => { + const repos: RepoConfig[] = [{ id: 'repo-1', name: 'treebeard', path: '/repo' }] + + useCollapsedMock.mockReturnValue({ collapsed: new Set(['repo-1']), toggle: vi.fn() }) + useWorktreesMock.mockReturnValue({ + worktrees: [ + { path: '/repo/wt/main', branch: 'main', head: 'abc', isMain: true }, + { path: '/repo/wt/feat', branch: 'feat/testing', head: 'def', isMain: false } + ], + loading: false, + loaded: true, + error: null, + deleteError: null, + deletingPaths: new Set(), + startDelete: vi.fn(), + clearDeleteError: vi.fn(), + settingUpPaths: new Set(), + setupError: null, + startSetup: vi.fn(), + clearSetupError: vi.fn(), + refresh: vi.fn() + }) + + renderWithMantine( + {}} + isDraggingJira={false} + overRepoId={null} + jiraDropTargets={{}} + onJiraDropBranchClear={() => {}} + /> + ) + + expect(screen.getByTestId('main-status').textContent).toBe('/repo/wt/main') + expect(screen.getByTestId('main-actions').textContent).toBe('intellij:/repo/wt/main') + }) + + it('can surface main worktrees through search', async () => { + const repos: RepoConfig[] = [{ id: 'repo-1', name: 'treebeard', path: '/repo' }] + + useWorktreesMock.mockReturnValue({ + worktrees: [ + { path: '/repo/wt/main', branch: 'main', head: 'abc', isMain: true }, + { path: '/repo/wt/feat', branch: 'feat/testing', head: 'def', isMain: false } + ], + loading: false, + loaded: true, + error: null, + deleteError: null, + deletingPaths: new Set(), + startDelete: vi.fn(), + clearDeleteError: vi.fn(), + settingUpPaths: new Set(), + setupError: null, + startSetup: vi.fn(), + clearSetupError: vi.fn(), + refresh: vi.fn() + }) + + renderWithMantine( + {}} + isDraggingJira={false} + overRepoId={null} + jiraDropTargets={{}} + onJiraDropBranchClear={() => {}} + /> + ) + + expect(await screen.findByText('main')).toBeTruthy() + expect(screen.queryByText('feat/testing')).toBeNull() + }) }) diff --git a/packages/treebeard/src/components/RepoDashboard.tsx b/packages/treebeard/src/components/RepoDashboard.tsx index 77cac4a..acc3fae 100644 --- a/packages/treebeard/src/components/RepoDashboard.tsx +++ b/packages/treebeard/src/components/RepoDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { Stack, Group, Title, Text, ActionIcon, Loader, Alert, Collapse, Code } from '@mantine/core' import { IconRefresh, IconPlus, IconChevronDown, IconChevronRight, IconGripVertical, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react' import { @@ -9,14 +9,37 @@ import { import { CSS } from '@dnd-kit/utilities' import { WorktreeCard } from './WorktreeCard' import { AddWorktreeModal } from './AddWorktreeModal' +import { DirtyBadge } from './DirtyBadge' +import { LaunchButtons } from './LaunchButtons' import { useWorktrees } from '../hooks/useWorktrees' import { useCollapsed } from '../hooks/useCollapsed' import { useHomedir } from '../hooks/useHomedir' import { useFetchRepo } from '../hooks/useFetchRepo' -import type { IdeId, RepoConfig } from '../shared/types' +import { useWorktreeStatus } from '../hooks/useWorktreeStatus' +import type { IdeId, RepoConfig, Worktree } from '../shared/types' + +type RepoActivity = 'active' | 'inactive' | 'unknown' // --- RepoSection --- +interface MainWorktreeControlsProps { + worktree: Worktree + pollIntervalSec: number + refreshKey: number + defaultIde: IdeId +} + +function MainWorktreeControls({ worktree, pollIntervalSec, refreshKey, defaultIde }: MainWorktreeControlsProps) { + const { status, loading, refresh } = useWorktreeStatus(worktree.path, pollIntervalSec, refreshKey) + + return ( + + + + + ) +} + interface RepoSectionProps { repo: RepoConfig pollIntervalSec: number @@ -28,6 +51,7 @@ interface RepoSectionProps { isDropTarget: boolean isOver: boolean jiraDropBranch: string | null + onActivityChange: (repoId: string, activity: RepoActivity) => void onJiraDropBranchClear: () => void } @@ -42,9 +66,10 @@ function RepoSection({ isDropTarget, isOver, jiraDropBranch, + onActivityChange, onJiraDropBranchClear }: RepoSectionProps) { - const { worktrees, loading, error, deleteError, deletingPaths, startDelete, clearDeleteError, settingUpPaths, setupError, startSetup, clearSetupError, refresh } = useWorktrees(repo.path, pollIntervalSec) + const { worktrees, loading, loaded, error, deleteError, deletingPaths, startDelete, clearDeleteError, settingUpPaths, setupError, startSetup, clearSetupError, refresh } = useWorktrees(repo.path, pollIntervalSec) const [addOpened, setAddOpened] = useState(false) const [refreshKey, setRefreshKey] = useState(0) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: repo.id }) @@ -72,15 +97,25 @@ function RepoSection({ const dropHighlight = isDropTarget && isOver const query = search.toLowerCase() - const filtered = query + const visibleWorktrees = query ? worktrees.filter( (wt) => wt.branch.toLowerCase().includes(query) || wt.path.toLowerCase().includes(query) ) - : worktrees + : worktrees.filter((wt) => !wt.isMain) + const mainWorktree = worktrees.find((wt) => wt.isMain) + const hasActiveWorktrees = worktrees.some((wt) => !wt.isMain) + const activity: RepoActivity = loaded && !error + ? hasActiveWorktrees ? 'active' : 'inactive' + : 'unknown' + const shouldShowBody = loading || Boolean(error) || Boolean(deleteError) || Boolean(setupError) || visibleWorktrees.length > 0 - if (!loading && filtered.length === 0 && query) return null + useEffect(() => { + onActivityChange(repo.id, activity) + }, [repo.id, activity, onActivityChange]) + + if (!loading && visibleWorktrees.length === 0 && query) return null return (
- - {isCollapsed ? : } - - + {shouldShowBody && ( + <ActionIcon variant="subtle" color="dimmed" size="sm" onClick={onToggleCollapse}> + {isCollapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />} + </ActionIcon> + )} + <Title + order={4} + style={{ fontFamily: 'monospace', cursor: shouldShowBody ? 'pointer' : 'default' }} + onClick={shouldShowBody ? onToggleCollapse : undefined} + > {repo.name} {shortenPath(repo.path)} - + + {mainWorktree && ( + + )} setAddOpened(true)}> @@ -144,7 +193,7 @@ function RepoSection({ initialBranch={jiraDropBranch ?? undefined} /> - + {error && ( {error} )} @@ -181,7 +230,7 @@ function RepoSection({ ) : ( - {filtered.map((wt) => ( + {visibleWorktrees.map((wt) => ( void } +function activityRank(activity: RepoActivity | undefined): number { + if (activity === 'active') return 0 + if (activity === 'inactive') return 2 + return 1 +} + export function RepoDashboard({ repos, pollIntervalSec, @@ -231,11 +286,39 @@ export function RepoDashboard({ }: RepoDashboardProps) { const { collapsed, toggle } = useCollapsed() const [orderedRepos, setOrderedRepos] = useState(repos) + const [repoActivityById, setRepoActivityById] = useState>({}) useEffect(() => { setOrderedRepos(repos) }, [repos]) + useEffect(() => { + setRepoActivityById((prev) => { + const repoIds = new Set(repos.map((repo) => repo.id)) + const next: Record = {} + for (const [repoId, activity] of Object.entries(prev)) { + if (repoIds.has(repoId)) next[repoId] = activity + } + return Object.keys(next).length === Object.keys(prev).length ? prev : next + }) + }, [repos]) + + const handleActivityChange = useCallback((repoId: string, activity: RepoActivity) => { + setRepoActivityById((prev) => { + if (prev[repoId] === activity) return prev + return { ...prev, [repoId]: activity } + }) + }, []) + + const displayRepos = useMemo(() => { + const orderById = new Map(orderedRepos.map((repo, index) => [repo.id, index])) + return [...orderedRepos].sort((a, b) => { + const rankDiff = activityRank(repoActivityById[a.id]) - activityRank(repoActivityById[b.id]) + if (rankDiff !== 0) return rankDiff + return (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0) + }) + }, [orderedRepos, repoActivityById]) + if (orderedRepos.length === 0) { return ( @@ -246,9 +329,9 @@ export function RepoDashboard({ } return ( - r.id)} strategy={verticalListSortingStrategy}> + r.id)} strategy={verticalListSortingStrategy}> - {orderedRepos.map((repo) => ( + {displayRepos.map((repo) => ( onJiraDropBranchClear(repo.id)} /> ))} diff --git a/packages/treebeard/src/hooks/useWorktrees.test.ts b/packages/treebeard/src/hooks/useWorktrees.test.ts index e3ff507..ffdfe29 100644 --- a/packages/treebeard/src/hooks/useWorktrees.test.ts +++ b/packages/treebeard/src/hooks/useWorktrees.test.ts @@ -26,6 +26,7 @@ describe('useWorktrees', () => { await waitFor(() => { expect(result.current.loading).toBe(false) + expect(result.current.loaded).toBe(true) expect(result.current.worktrees).toHaveLength(1) }) @@ -44,6 +45,7 @@ describe('useWorktrees', () => { await waitFor(() => { expect(result.current.loading).toBe(false) + expect(result.current.loaded).toBe(true) expect(result.current.error).toBe('rpc failed') expect(result.current.worktrees).toEqual([]) }) diff --git a/packages/treebeard/src/hooks/useWorktrees.ts b/packages/treebeard/src/hooks/useWorktrees.ts index 6434df9..cc1de73 100644 --- a/packages/treebeard/src/hooks/useWorktrees.ts +++ b/packages/treebeard/src/hooks/useWorktrees.ts @@ -10,6 +10,7 @@ export interface SetupFailure { export function useWorktrees(repoPath: string | null, pollIntervalSec: number) { const [worktrees, setWorktrees] = useState([]) const [loading, setLoading] = useState(false) + const [loaded, setLoaded] = useState(false) const [error, setError] = useState(null) const [deletingPaths, setDeletingPaths] = useState>(new Set()) const [deleteError, setDeleteError] = useState(null) @@ -36,6 +37,7 @@ export function useWorktrees(repoPath: string | null, pollIntervalSec: number) { setError(err instanceof Error ? err.message : 'Failed to list worktrees') setWorktrees([]) } finally { + setLoaded(true) setLoading(false) } }, [repoPath]) @@ -112,6 +114,7 @@ export function useWorktrees(repoPath: string | null, pollIntervalSec: number) { return { worktrees, loading, + loaded, error, deleteError, deletingPaths,