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 && (
+
+ {isCollapsed ? : }
+
+ )}
+
{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,