feat: add BranchMap sidebar with BranchNode tree visualization

BranchNode renders a branch button with depth-based indent, status icon,
and status badge using the design system color palette. BranchMap builds
a tree from the flat branch list via buildTree/flattenTree and renders
all nodes with a GitBranch header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-24 09:51:30 +00:00
parent f5056d2e84
commit c0dfa7c230
2 changed files with 172 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import { GitBranch } from 'lucide-react'
import { BranchNode } from './BranchNode'
import type { BranchResponse } from '@/types/branching'
interface BranchTreeNode {
branch: BranchResponse
depth: number
children: BranchTreeNode[]
}
function buildTree(branches: BranchResponse[]): BranchTreeNode[] {
const map = new Map<string, BranchTreeNode>()
const roots: BranchTreeNode[] = []
// First pass: create nodes
for (const branch of branches) {
map.set(branch.id, { branch, depth: 0, children: [] })
}
// Second pass: assign parents / roots
for (const branch of branches) {
const node = map.get(branch.id)!
if (branch.parent_branch_id && map.has(branch.parent_branch_id)) {
const parent = map.get(branch.parent_branch_id)!
node.depth = parent.depth + 1
parent.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
function flattenTree(nodes: BranchTreeNode[]): Array<{ branch: BranchResponse; depth: number }> {
const result: Array<{ branch: BranchResponse; depth: number }> = []
for (const node of nodes) {
result.push({ branch: node.branch, depth: node.depth })
if (node.children.length > 0) {
result.push(...flattenTree(node.children))
}
}
return result
}
interface BranchMapProps {
branches: BranchResponse[]
activeBranchId: string | null
onSelectBranch: (branchId: string) => void
}
export function BranchMap({ branches, activeBranchId, onSelectBranch }: BranchMapProps) {
const roots = buildTree(branches)
const flat = flattenTree(roots)
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 px-2 pb-2">
<GitBranch size={14} className="text-muted" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted">
Branch Map
</span>
<span className="ml-auto text-[10px] text-muted">{branches.length}</span>
</div>
{flat.length === 0 ? (
<p className="px-2 text-xs text-muted">No branches yet.</p>
) : (
flat.map(({ branch, depth }) => (
<BranchNode
key={branch.id}
branch={branch}
depth={depth}
isActive={branch.id === activeBranchId}
onClick={onSelectBranch}
/>
))
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { BranchResponse } from '@/types/branching'
type BranchStatus = BranchResponse['status']
interface StatusConfig {
icon: React.ElementType
textClass: string
badgeClass: string
label: string
}
const STATUS_CONFIG: Record<BranchStatus, StatusConfig> = {
active: {
icon: CircleDot,
textClass: 'text-accent',
badgeClass: 'bg-accent-dim text-accent-text',
label: 'Active',
},
solved: {
icon: CheckCircle2,
textClass: 'text-[#34d399]',
badgeClass: 'bg-[rgba(52,211,153,0.10)] text-[#34d399]',
label: 'Solved',
},
dead_end: {
icon: XCircle,
textClass: 'text-[#f87171]',
badgeClass: 'bg-[rgba(248,113,113,0.10)] text-[#f87171]',
label: 'Dead End',
},
untried: {
icon: Circle,
textClass: 'text-muted',
badgeClass: 'bg-[rgba(79,86,102,0.20)] text-muted',
label: 'Untried',
},
revived: {
icon: RotateCcw,
textClass: 'text-[#eab308]',
badgeClass: 'bg-[rgba(234,179,8,0.10)] text-[#eab308]',
label: 'Revived',
},
}
interface BranchNodeProps {
branch: BranchResponse
depth: number
isActive: boolean
onClick: (branchId: string) => void
}
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
const config = STATUS_CONFIG[branch.status]
const Icon = config.icon
return (
<button
type="button"
onClick={() => onClick(branch.id)}
style={{ paddingLeft: `${8 + depth * 16}px` }}
className={cn(
'w-full flex items-center gap-2 py-2 pr-3 text-left rounded-md transition-colors',
'hover:bg-elevated',
isActive && 'bg-accent-dim border-l-2 border-accent'
)}
>
<Icon
size={14}
className={cn('shrink-0', config.textClass)}
/>
<span
className={cn(
'flex-1 text-sm truncate',
isActive ? 'text-heading font-medium' : 'text-primary'
)}
>
{branch.label}
</span>
<span
className={cn(
'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0',
config.badgeClass
)}
>
{config.label}
</span>
</button>
)
}