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:
81
frontend/src/components/session/BranchMap.tsx
Normal file
81
frontend/src/components/session/BranchMap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
frontend/src/components/session/BranchNode.tsx
Normal file
91
frontend/src/components/session/BranchNode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user