gitpilot / frontend /components /BranchPicker.jsx
github-actions[bot]
Deploy from 8300de42
4f008ff
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 (
<div style={styles.container}>
{/* Trigger button — hidden when using external anchor */}
{!isExternalMode && (
<button
ref={triggerRef}
type="button"
style={{
...styles.trigger,
borderColor: isAiSession ? "rgba(59, 130, 246, 0.3)" : "#3F3F46",
color: isAiSession ? "#60a5fa" : "#E4E4E7",
backgroundColor: isAiSession ? "rgba(59, 130, 246, 0.05)" : "transparent",
}}
onClick={() => setOpen((v) => !v)}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<span style={styles.branchName}>{branch}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
{/* Dropdown — portaled to document.body to escape overflow:hidden */}
{open && createPortal(
<div
ref={dropdownRef}
style={{
...styles.dropdown,
top: pos.top,
left: pos.left,
}}
>
{/* Search input */}
<div style={styles.searchBox}>
<input
ref={inputRef}
type="text"
placeholder="Search branches..."
value={query}
onChange={(e) => setQuery(e.target.value)}
style={styles.searchInput}
onKeyDown={(e) => {
if (e.key === "Escape") {
handleClose();
}
}}
/>
</div>
{/* Branch list */}
<div style={styles.branchList}>
{loading && allBranches.length === 0 && (
<div style={styles.loadingRow}>Loading...</div>
)}
{!loading && error && (
<div style={styles.errorRow}>{error}</div>
)}
{!loading && !error && allBranches.length === 0 && (
<div style={styles.loadingRow}>No branches found</div>
)}
{allBranches.map((b) => {
const isDefault = b.is_default || b.name === defaultBranch;
const isAi = sessionBranches.includes(b.name);
const isCurrent = b.name === branch;
return (
<div
key={b.name}
style={{
...styles.branchRow,
backgroundColor: isCurrent
? isAi
? "rgba(59, 130, 246, 0.10)"
: "#27272A"
: "transparent",
}}
onMouseDown={() => handleSelect(b.name)}
>
<span style={{ opacity: isCurrent ? 1 : 0, width: 16, flexShrink: 0 }}>
&#10003;
</span>
<span
style={{
flex: 1,
fontFamily: "monospace",
fontSize: 12,
color: isAi ? "#60a5fa" : "#E4E4E7",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{b.name}
</span>
{isDefault && (
<span style={styles.defaultBadge}>default</span>
)}
{isAi && !isDefault && (
<span style={styles.aiBadge}>AI</span>
)}
{b.protected && (
<span style={styles.protectedBadge}>
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z" />
</svg>
</span>
)}
</div>
);
})}
{/* Subtle loading indicator when refreshing with cached data visible */}
{loading && allBranches.length > 0 && (
<div style={styles.loadingRow}>Updating...</div>
)}
</div>
</div>,
document.body
)}
</div>
);
}
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",
},
};