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