import React, { useEffect, useMemo, useRef, useState } from "react"; import FileTree from "./FileTree.jsx"; import BranchPicker from "./BranchPicker.jsx"; // --- INJECTED STYLES FOR ANIMATIONS --- const animationStyles = ` @keyframes highlight-pulse { 0% { background-color: rgba(59, 130, 246, 0.10); } 50% { background-color: rgba(59, 130, 246, 0.22); } 100% { background-color: transparent; } } .pulse-context { animation: highlight-pulse 1.1s ease-out; } `; /** * ProjectContextPanel (Production-ready) * * Controlled component: * - Branch source of truth is App.jsx: * - defaultBranch (prod) * - currentBranch (what user sees) * - sessionBranches (list of all active AI session branches) * * Responsibilities: * - Show project context + branch dropdown + AI badge/banner * - Fetch access status + file count for the currentBranch * - Trigger visual pulse on pulseNonce (Hard Switch) */ export default function ProjectContextPanel({ repo, defaultBranch, currentBranch, sessionBranch, // Active session branch (optional, for specific highlighting) sessionBranches = [], // List of all AI branches onBranchChange, pulseNonce, onSettingsClick, }) { const [appUrl, setAppUrl] = useState(""); const [fileCount, setFileCount] = useState(0); const [isDropdownOpen, setIsDropdownOpen] = useState(false); // Data Loading State const [analyzing, setAnalyzing] = useState(false); const [accessInfo, setAccessInfo] = useState(null); const [treeError, setTreeError] = useState(null); // Retry / Refresh Logic const [refreshTrigger, setRefreshTrigger] = useState(0); const [retryCount, setRetryCount] = useState(0); const retryTimeoutRef = useRef(null); // UX State const [animateHeader, setAnimateHeader] = useState(false); const [toast, setToast] = useState({ visible: false, title: "", msg: "" }); // Calculate effective default to prevent 'main' fallback errors const effectiveDefaultBranch = defaultBranch || repo?.default_branch || "main"; const branch = currentBranch || effectiveDefaultBranch; // Determine if we are currently viewing an AI Session branch const isAiSession = (sessionBranches.includes(branch)) || (sessionBranch === branch && branch !== effectiveDefaultBranch); // Fetch App URL on mount useEffect(() => { fetch("/api/auth/app-url") .then((res) => res.json()) .then((data) => { if (data.app_url) setAppUrl(data.app_url); }) .catch((err) => console.error("Failed to fetch App URL:", err)); }, []); // Hard Switch pulse: whenever App increments pulseNonce useEffect(() => { if (!pulseNonce) return; setAnimateHeader(true); const t = window.setTimeout(() => setAnimateHeader(false), 1100); return () => window.clearTimeout(t); }, [pulseNonce]); // Main data fetcher (Access + Tree stats) for currentBranch // Stale-while-revalidate: keep previous data visible during fetch useEffect(() => { if (!repo) return; // Only show full "analyzing" spinner if we have no data yet if (!accessInfo) setAnalyzing(true); setTreeError(null); if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } let headers = {}; try { const token = localStorage.getItem("github_token"); if (token) headers = { Authorization: `Bearer ${token}` }; } catch (e) { console.warn("Unable to read github_token:", e); } let cancelled = false; const cacheBuster = `&_t=${Date.now()}&retry=${retryCount}`; // A) Access Check (with Stale Cache Fix) fetch(`/api/auth/repo-access?owner=${repo.owner}&repo=${repo.name}${cacheBuster}`, { headers, cache: "no-cache", }) .then(async (res) => { if (cancelled) return; const data = await res.json().catch(() => ({})); if (!res.ok) { setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" }); return; } setAccessInfo(data); // Auto-retry if user has push access but App is not detected yet (Stale Cache) if (data.can_write && !data.app_installed && retryCount === 0) { retryTimeoutRef.current = setTimeout(() => { setRetryCount(1); }, 1000); } }) .catch(() => { if (!cancelled) setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" }); }); // B) Tree count for the selected branch // Don't clear fileCount — keep stale value visible until new one arrives const hadFileCount = fileCount > 0; if (!hadFileCount) setAnalyzing(true); fetch(`/api/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(branch)}&_t=${Date.now()}`, { headers, cache: "no-cache", }) .then(async (res) => { if (cancelled) return; const data = await res.json().catch(() => ({})); if (!res.ok) { setTreeError(data.detail || "Failed to load tree"); setFileCount(0); return; } setFileCount(Array.isArray(data.files) ? data.files.length : 0); }) .catch((err) => { if (cancelled) return; setTreeError(err.message); setFileCount(0); }) .finally(() => { if (!cancelled) setAnalyzing(false); }); return () => { cancelled = true; if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [repo?.owner, repo?.name, branch, refreshTrigger, retryCount]); const showToast = (title, msg) => { setToast({ visible: true, title, msg }); setTimeout(() => setToast((prev) => ({ ...prev, visible: false })), 3000); }; const handleManualSwitch = (targetBranch) => { if (!targetBranch || targetBranch === branch) { setIsDropdownOpen(false); return; } // Local UI feedback (App.jsx will handle the actual state change) const goingAi = sessionBranches.includes(targetBranch); showToast( goingAi ? "Context Switched" : "Switched to Production", goingAi ? `Viewing AI Session: ${targetBranch}` : `Viewing ${targetBranch}.` ); setIsDropdownOpen(false); if (onBranchChange) onBranchChange(targetBranch); }; const handleRefresh = () => { setAnalyzing(true); setRetryCount(0); setRefreshTrigger((prev) => prev + 1); }; const handleInstallClick = () => { if (!appUrl) return; const targetUrl = appUrl.endsWith("/") ? `${appUrl}installations/new` : `${appUrl}/installations/new`; window.open(targetUrl, "_blank", "noopener,noreferrer"); }; // --- STYLES --- const theme = useMemo( () => ({ bg: "#131316", border: "#27272A", textPrimary: "#EDEDED", textSecondary: "#A1A1AA", accent: "#3b82f6", warningBorder: "rgba(245, 158, 11, 0.2)", warningText: "#F59E0B", successColor: "#10B981", cardBg: "#18181B", aiBg: "rgba(59, 130, 246, 0.10)", aiBorder: "rgba(59, 130, 246, 0.30)", aiText: "#60a5fa", }), [] ); const styles = useMemo( () => ({ container: { height: "100%", borderRight: `1px solid ${theme.border}`, backgroundColor: theme.bg, display: "flex", flexDirection: "column", fontFamily: '"Söhne", "Inter", sans-serif', position: "relative", overflow: "hidden", }, header: { padding: "16px 20px", borderBottom: `1px solid ${theme.border}`, display: "flex", alignItems: "center", justifyContent: "space-between", transition: "background-color 0.3s ease", }, titleGroup: { display: "flex", alignItems: "center", gap: "8px" }, title: { fontSize: "13px", fontWeight: "600", color: theme.textPrimary }, repoBadge: { backgroundColor: "#27272A", color: theme.textSecondary, fontSize: "11px", padding: "2px 8px", borderRadius: "12px", border: `1px solid ${theme.border}`, fontFamily: "monospace", }, aiBadge: { display: "flex", alignItems: "center", gap: "6px", backgroundColor: theme.aiBg, color: theme.aiText, fontSize: "10px", fontWeight: "bold", padding: "2px 8px", borderRadius: "12px", border: `1px solid ${theme.aiBorder}`, textTransform: "uppercase", letterSpacing: "0.5px", }, content: { padding: "16px 20px 12px 20px", display: "flex", flexDirection: "column", gap: "12px", }, statRow: { display: "flex", justifyContent: "space-between", fontSize: "13px", marginBottom: "4px" }, label: { color: theme.textSecondary }, value: { color: theme.textPrimary, fontWeight: "500" }, dropdownContainer: { position: "relative" }, branchButton: { display: "flex", alignItems: "center", gap: "6px", padding: "4px 8px", borderRadius: "4px", border: `1px solid ${isAiSession ? theme.aiBorder : theme.border}`, backgroundColor: isAiSession ? "rgba(59, 130, 246, 0.05)" : "transparent", color: isAiSession ? theme.aiText : theme.textPrimary, fontSize: "13px", cursor: "pointer", fontFamily: "monospace", }, dropdownMenu: { position: "absolute", top: "100%", left: 0, marginTop: "4px", width: "240px", backgroundColor: "#1F1F23", border: `1px solid ${theme.border}`, borderRadius: "6px", boxShadow: "0 4px 12px rgba(0,0,0,0.5)", zIndex: 50, display: isDropdownOpen ? "block" : "none", overflow: "hidden", }, dropdownItem: { padding: "8px 12px", fontSize: "13px", color: theme.textSecondary, cursor: "pointer", display: "flex", alignItems: "center", gap: "8px", borderBottom: `1px solid ${theme.border}`, }, contextBanner: { backgroundColor: theme.aiBg, borderTop: `1px solid ${theme.aiBorder}`, padding: "8px 20px", fontSize: "11px", color: theme.aiText, display: "flex", justifyContent: "space-between", alignItems: "center", }, toast: { position: "absolute", top: "16px", right: "16px", backgroundColor: "#18181B", border: `1px solid ${theme.border}`, borderLeft: `3px solid ${theme.accent}`, borderRadius: "6px", padding: "12px", boxShadow: "0 4px 12px rgba(0,0,0,0.5)", zIndex: 100, minWidth: "240px", transition: "all 0.3s cubic-bezier(0.16, 1, 0.3, 1)", transform: toast.visible ? "translateX(0)" : "translateX(120%)", opacity: toast.visible ? 1 : 0, }, toastTitle: { fontSize: "13px", fontWeight: "bold", color: theme.textPrimary, marginBottom: "2px" }, toastMsg: { fontSize: "11px", color: theme.textSecondary }, refreshButton: { marginTop: "8px", height: "32px", padding: "0 12px", backgroundColor: "transparent", color: theme.textSecondary, border: `1px solid ${theme.border}`, borderRadius: "6px", fontSize: "12px", cursor: analyzing ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", }, settingsBtn: { display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "6px", border: `1px solid ${theme.border}`, backgroundColor: "transparent", color: theme.textSecondary, cursor: "pointer", padding: 0, transition: "color 0.15s, border-color 0.15s", }, treeWrapper: { flex: 1, overflow: "auto", borderTop: `1px solid ${theme.border}` }, installCard: { marginTop: "8px", padding: "12px", borderRadius: "8px", backgroundColor: theme.cardBg, border: `1px solid ${theme.warningBorder}`, }, installHeader: { display: "flex", alignItems: "center", gap: "10px", fontSize: "14px", fontWeight: "600", color: theme.textPrimary, }, installText: { fontSize: "13px", color: theme.textSecondary, lineHeight: "1.5", }, }), [analyzing, isAiSession, isDropdownOpen, theme, toast.visible] ); // Determine status text let statusText = "Checking..."; let statusColor = theme.textSecondary; let showInstallCard = false; if (!analyzing && accessInfo) { if (accessInfo.app_installed) { statusText = "Write Access ✓"; statusColor = theme.successColor; } else if (accessInfo.can_write && retryCount === 0) { statusText = "Verifying..."; } else if (accessInfo.can_write) { statusText = "Push Access (No App)"; statusColor = theme.warningText; showInstallCard = true; } else { statusText = "Read Only"; statusColor = theme.warningText; showInstallCard = true; } } if (!repo) { return (
Install the GitPilot App to enable AI agent operations.