import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; /** * BranchPicker — Claude-Code-on-Web parity branch selector. * * Fetches branches from the new /api/repos/{owner}/{repo}/branches endpoint. * Shows search, default branch badge, AI session branch highlighting. * * Fixes applied: * - Dropdown portaled to document.body (avoids overflow:hidden clipping) * - Branches cached per repo (no "No branches found" flash) * - Shows "Loading..." only on first fetch, keeps stale data otherwise */ // Simple per-repo branch cache so reopening the dropdown is instant const branchCache = {}; /** * Props: * repo, currentBranch, defaultBranch, sessionBranches, onBranchChange * — standard branch-picker props * * externalAnchorRef (optional) — a React ref pointing to an external DOM * element to anchor the dropdown to. When provided: * - BranchPicker skips rendering its own trigger button * - the dropdown opens immediately on mount * - closing the dropdown calls onClose() * * onClose (optional) — called when the dropdown is dismissed (outside * click or Escape). Only meaningful with externalAnchorRef. */ export default function BranchPicker({ repo, currentBranch, defaultBranch, sessionBranches = [], onBranchChange, externalAnchorRef, onClose, }) { const isExternalMode = !!externalAnchorRef; const [open, setOpen] = useState(isExternalMode); const [query, setQuery] = useState(""); const [branches, setBranches] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const triggerRef = useRef(null); const dropdownRef = useRef(null); const inputRef = useRef(null); const branch = currentBranch || defaultBranch || "main"; const isAiSession = sessionBranches.includes(branch) && branch !== defaultBranch; // The element used for dropdown positioning const anchorRef = isExternalMode ? externalAnchorRef : triggerRef; const cacheKey = repo ? `${repo.owner}/${repo.name}` : null; // Seed from cache on mount / repo change useEffect(() => { if (cacheKey && branchCache[cacheKey]) { setBranches(branchCache[cacheKey]); } }, [cacheKey]); // Fetch branches from GitHub via backend const fetchBranches = useCallback(async (searchQuery) => { if (!repo) return; setLoading(true); setError(null); try { const token = localStorage.getItem("github_token"); const headers = token ? { Authorization: `Bearer ${token}` } : {}; const params = new URLSearchParams({ per_page: "100" }); if (searchQuery) params.set("query", searchQuery); const res = await fetch( `/api/repos/${repo.owner}/${repo.name}/branches?${params}`, { headers, cache: "no-cache" } ); if (!res.ok) { const errData = await res.json().catch(() => ({})); const detail = errData.detail || `HTTP ${res.status}`; console.warn("BranchPicker: fetch failed:", detail); setError(detail); return; } const data = await res.json(); const fetched = data.branches || []; setBranches(fetched); // Only cache the unfiltered result if (!searchQuery && cacheKey) { branchCache[cacheKey] = fetched; } } catch (err) { console.warn("Failed to fetch branches:", err); } finally { setLoading(false); } }, [repo, cacheKey]); // Fetch + focus when opened useEffect(() => { if (open) { fetchBranches(query); setTimeout(() => inputRef.current?.focus(), 50); } }, [open]); // eslint-disable-line react-hooks/exhaustive-deps // Debounced search useEffect(() => { if (!open) return; const t = setTimeout(() => fetchBranches(query), 300); return () => clearTimeout(t); }, [query, open, fetchBranches]); // Close on outside click useEffect(() => { if (!open) return; const handler = (e) => { const inAnchor = anchorRef.current && anchorRef.current.contains(e.target); const inDropdown = dropdownRef.current && dropdownRef.current.contains(e.target); if (!inAnchor && !inDropdown) { handleClose(); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps const handleClose = useCallback(() => { setOpen(false); setQuery(""); onClose?.(); }, [onClose]); const handleSelect = (branchName) => { handleClose(); if (branchName !== branch) { onBranchChange?.(branchName); } }; // Merge API branches with session branches (AI branches might not show in GitHub API) const allBranches = [...branches]; for (const sb of sessionBranches) { if (!allBranches.find((b) => b.name === sb)) { allBranches.push({ name: sb, is_default: false, protected: false }); } } // Calculate portal position from anchor element const getDropdownPosition = () => { if (!anchorRef.current) return { top: 0, left: 0 }; const rect = anchorRef.current.getBoundingClientRect(); return { top: rect.bottom + 4, left: rect.left, }; }; const pos = open ? getDropdownPosition() : { top: 0, left: 0 }; return (
{/* Trigger button — hidden when using external anchor */} {!isExternalMode && ( )} {/* Dropdown — portaled to document.body to escape overflow:hidden */} {open && createPortal(
{/* Search input */}
setQuery(e.target.value)} style={styles.searchInput} onKeyDown={(e) => { if (e.key === "Escape") { handleClose(); } }} />
{/* Branch list */}
{loading && allBranches.length === 0 && (
Loading...
)} {!loading && error && (
{error}
)} {!loading && !error && allBranches.length === 0 && (
No branches found
)} {allBranches.map((b) => { const isDefault = b.is_default || b.name === defaultBranch; const isAi = sessionBranches.includes(b.name); const isCurrent = b.name === branch; return (
handleSelect(b.name)} > {b.name} {isDefault && ( default )} {isAi && !isDefault && ( AI )} {b.protected && ( )}
); })} {/* Subtle loading indicator when refreshing with cached data visible */} {loading && allBranches.length > 0 && (
Updating...
)}
, document.body )}
); } const styles = { container: { position: "relative", }, trigger: { display: "flex", alignItems: "center", gap: 6, padding: "4px 8px", borderRadius: 4, border: "1px solid #3F3F46", background: "transparent", fontSize: 13, cursor: "pointer", fontFamily: "monospace", maxWidth: 200, }, branchName: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 140, }, dropdown: { position: "fixed", width: 280, backgroundColor: "#1F1F23", border: "1px solid #27272A", borderRadius: 8, boxShadow: "0 8px 24px rgba(0,0,0,0.6)", zIndex: 9999, overflow: "hidden", }, searchBox: { padding: "8px 10px", borderBottom: "1px solid #27272A", }, searchInput: { width: "100%", padding: "6px 8px", borderRadius: 4, border: "1px solid #3F3F46", background: "#131316", color: "#E4E4E7", fontSize: 12, outline: "none", fontFamily: "monospace", boxSizing: "border-box", }, branchList: { maxHeight: 260, overflowY: "auto", }, branchRow: { display: "flex", alignItems: "center", gap: 6, padding: "7px 10px", cursor: "pointer", transition: "background-color 0.1s", borderBottom: "1px solid rgba(39, 39, 42, 0.5)", }, loadingRow: { padding: "12px 10px", textAlign: "center", fontSize: 12, color: "#71717A", }, errorRow: { padding: "12px 10px", textAlign: "center", fontSize: 11, color: "#F59E0B", }, defaultBadge: { fontSize: 9, padding: "1px 5px", borderRadius: 8, backgroundColor: "rgba(16, 185, 129, 0.15)", color: "#10B981", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", flexShrink: 0, }, aiBadge: { fontSize: 9, padding: "1px 5px", borderRadius: 8, backgroundColor: "rgba(59, 130, 246, 0.15)", color: "#60a5fa", fontWeight: 700, flexShrink: 0, }, protectedBadge: { color: "#F59E0B", flexShrink: 0, display: "flex", alignItems: "center", }, };