Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/treebeard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Stack,
Group,
Tooltip,
SegmentedControl,
createTheme
} from '@mantine/core'
import { IconSettings, IconSearch, IconX, IconTicket } from '@tabler/icons-react'
Expand Down Expand Up @@ -84,6 +85,7 @@ export default function App() {
} = useConfig()
const [settingsOpened, setSettingsOpened] = useState(false)
const [search, setSearch] = useState('')
const [viewMode, setViewMode] = useState<'repo' | 'name'>('repo')
const [dependencyStatus, setDependencyStatus] = useState<DependencyStatus | null>(null)
const [jiraDropTargets, setJiraDropTargets] = useState<Record<string, string | null>>({})
const [orderedRepos, setOrderedRepos] = useState<RepoConfig[]>([])
Expand Down Expand Up @@ -226,6 +228,15 @@ export default function App() {
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ width: 220 }}
/>
<SegmentedControl
size="xs"
value={viewMode}
onChange={(v) => setViewMode(v as 'repo' | 'name')}
data={[
{ label: 'Repos', value: 'repo' },
{ label: 'Names', value: 'name' },
]}
/>
<Tooltip label={jiraPanelOpen ? 'Hide my Jira issues' : 'Show my Jira issues'} openDelay={500}>
<ActionIcon
variant={jiraPanelOpen ? 'light' : 'subtle'}
Expand Down Expand Up @@ -277,6 +288,7 @@ export default function App() {
fetchIntervalSec={config.fetchIntervalSec}
search={search}
defaultIde={config.defaultIde}
viewMode={viewMode}
onReorder={(repos) => { setOrderedRepos(repos); void reorderRepos(repos) }}
isDraggingJira={isDraggingJira}
overRepoId={overRepoId}
Expand Down
4 changes: 4 additions & 0 deletions packages/treebeard/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getRemoteBranches,
getWorktreeStatus,
removeWorktree,
renameWorktree,
runSetupCommands,
pullWorktree,
fetchRepo
Expand Down Expand Up @@ -194,6 +195,9 @@ const mainviewRPC = BrowserView.defineRPC<TreebeardRPC>({
'git:removeWorktree': async ({ repoPath, worktreePath, force }) => {
return removeWorktree(repoPath, worktreePath, force)
},
'git:renameWorktree': async ({ repoPath, worktreePath, newName }) => {
return renameWorktree(repoPath, worktreePath, newName)
},
'jira:issue': async ({ issueKey }) => {
return getJiraIssue(issueKey)
},
Expand Down
17 changes: 17 additions & 0 deletions packages/treebeard/src/bun/services/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,23 @@ export async function removeWorktree(
}
}

/** Rename a worktree by moving it to a new path within the same parent directory. */
export async function renameWorktree(
repoPath: string,
worktreePath: string,
newName: string
): Promise<{ success: boolean; error?: string }> {
const parentDir = path.dirname(worktreePath)
const newPath = path.join(parentDir, newName)
try {
await git(['worktree', 'move', worktreePath, newPath], repoPath)
return { success: true }
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
return { success: false, error: message }
}
}

/** Add a new worktree. If isNewBranch is true, creates the branch off baseBranch. */
export async function addWorktree(
repoPath: string,
Expand Down
234 changes: 234 additions & 0 deletions packages/treebeard/src/components/NameGroupDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { useState, useCallback, useMemo, useEffect } from 'react'
import { Stack, Group, Title, Text, ActionIcon, Loader, Collapse } from '@mantine/core'
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react'
import { WorktreeCard } from './WorktreeCard'
import { useWorktrees } from '../hooks/useWorktrees'
import { useFetchRepo } from '../hooks/useFetchRepo'
import type { IdeId, RepoConfig, Worktree } from '../shared/types'

// --- Per-repo data loader ---

interface RepoWorktreeEntry {
worktree: Worktree
repo: RepoConfig
repoPath: string
deleting: boolean
settingUp: boolean
onConfirmDelete: (force: boolean) => void
onRenamed: () => void
}

interface RepoLoaderProps {
repo: RepoConfig
pollIntervalSec: number
fetchIntervalSec: number
onData: (repoId: string, entries: RepoWorktreeEntry[], loading: boolean) => void
onFetched: () => void
}

function RepoLoader({ repo, pollIntervalSec, fetchIntervalSec, onData, onFetched }: RepoLoaderProps) {
const { worktrees, loading, deletingPaths, settingUpPaths, startDelete, refresh } = useWorktrees(repo.path, pollIntervalSec)

useFetchRepo(repo.path, fetchIntervalSec, onFetched)

const entries: RepoWorktreeEntry[] = useMemo(
() =>
worktrees
.filter((wt) => !wt.isMain)
.map((wt) => ({
worktree: wt,
repo,
repoPath: repo.path,
deleting: deletingPaths.has(wt.path),
settingUp: settingUpPaths.has(wt.path),
onConfirmDelete: (force: boolean) => startDelete(wt.path, force),
onRenamed: refresh,
})),
[worktrees, repo, deletingPaths, settingUpPaths, startDelete, refresh]
)

// Report data to parent
useEffect(() => {
onData(repo.id, entries, loading)
}, [repo.id, entries, loading, onData])

return null
}

// --- Name group section ---

interface NameGroup {
name: string
entries: RepoWorktreeEntry[]
}

interface NameGroupSectionProps {
group: NameGroup
pollIntervalSec: number
defaultIde: IdeId
refreshKey: number
}

function NameGroupSection({ group, pollIntervalSec, defaultIde, refreshKey }: NameGroupSectionProps) {
const [collapsed, setCollapsed] = useState(false)
const repoCount = new Set(group.entries.map((e) => e.repo.id)).size

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Group gap="xs">
<ActionIcon variant="subtle" color="dimmed" size="sm" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
</ActionIcon>
<Title
order={4}
style={{ fontFamily: 'monospace', cursor: 'pointer' }}
onClick={() => setCollapsed(!collapsed)}
>
{group.name}
</Title>
{repoCount > 1 && (
<Text size="xs" c="dimmed">
{repoCount} repos
</Text>
)}
</Group>

<Collapse in={!collapsed}>
<Stack gap="sm">
{group.entries.map((entry) => (
<WorktreeCard
key={entry.worktree.path}
worktree={entry.worktree}
repoPath={entry.repoPath}
pollIntervalSec={pollIntervalSec}
refreshKey={refreshKey}
defaultIde={defaultIde}
deleting={entry.deleting}
settingUp={entry.settingUp}
repoName={entry.repo.name}
onConfirmDelete={entry.onConfirmDelete}
onRenamed={entry.onRenamed}
/>
))}
</Stack>
</Collapse>
</div>
)
}

// --- Main dashboard ---

interface NameGroupDashboardProps {
repos: RepoConfig[]
pollIntervalSec: number
fetchIntervalSec: number
search: string
defaultIde: IdeId
}

function worktreeName(worktree: Worktree): string {
return worktree.path.split('/').pop() ?? worktree.path
}

export function NameGroupDashboard({
repos,
pollIntervalSec,
fetchIntervalSec,
search,
defaultIde
}: NameGroupDashboardProps) {
const [repoData, setRepoData] = useState<Record<string, RepoWorktreeEntry[]>>({})
const [repoLoading, setRepoLoading] = useState<Record<string, boolean>>({})
const [refreshKey, setRefreshKey] = useState(0)

const handleFetched = useCallback(() => setRefreshKey((k) => k + 1), [])

const handleData = useCallback((repoId: string, entries: RepoWorktreeEntry[], loading: boolean) => {
setRepoData((prev) => {
if (prev[repoId] === entries) return prev
return { ...prev, [repoId]: entries }
})
setRepoLoading((prev) => {
if (prev[repoId] === loading) return prev
return { ...prev, [repoId]: loading }
})
}, [])

const allEntries = useMemo(
() => Object.values(repoData).flat(),
[repoData]
)

const anyLoading = useMemo(
() => Object.values(repoLoading).some(Boolean),
[repoLoading]
)

const groups: NameGroup[] = useMemo(() => {
const query = search.toLowerCase()
const filtered = query
? allEntries.filter(
(e) =>
worktreeName(e.worktree).toLowerCase().includes(query) ||
e.worktree.branch.toLowerCase().includes(query) ||
e.worktree.path.toLowerCase().includes(query)
)
: allEntries

const map = new Map<string, RepoWorktreeEntry[]>()
for (const entry of filtered) {
const name = worktreeName(entry.worktree)
const existing = map.get(name)
if (existing) {
existing.push(entry)
} else {
map.set(name, [entry])
}
}

return Array.from(map.entries())
.map(([name, entries]) => ({ name, entries }))
.sort((a, b) => a.name.localeCompare(b.name))
}, [allEntries, search])

return (
<>
{repos.map((repo) => (
<RepoLoader
key={repo.id}
repo={repo}
pollIntervalSec={pollIntervalSec}
fetchIntervalSec={fetchIntervalSec}
onData={handleData}
onFetched={handleFetched}
/>
))}

{anyLoading && allEntries.length === 0 ? (
<Group justify="center" p="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading worktrees...</Text>
</Group>
) : groups.length === 0 ? (
<Stack align="center" justify="center" h={300} gap="md">
<Text size="lg" c="dimmed">No worktrees found</Text>
<Text size="sm" c="dimmed">
{search ? 'No worktrees match your filter.' : 'Create worktrees from the Repos view.'}
</Text>
</Stack>
) : (
<Stack gap="xl">
{groups.map((group) => (
<NameGroupSection
key={group.name}
group={group}
pollIntervalSec={pollIntervalSec}
defaultIde={defaultIde}
refreshKey={refreshKey}
/>
))}
</Stack>
)}
</>
)
}
Loading
Loading