| import React, { useEffect, useMemo, useRef, useState } from "react"; |
| import FileTree from "./FileTree.jsx"; |
| import BranchPicker from "./BranchPicker.jsx"; |
|
|
| |
| 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; |
| } |
| `; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| const [analyzing, setAnalyzing] = useState(false); |
| const [accessInfo, setAccessInfo] = useState(null); |
| const [treeError, setTreeError] = useState(null); |
|
|
| |
| const [refreshTrigger, setRefreshTrigger] = useState(0); |
| const [retryCount, setRetryCount] = useState(0); |
| const retryTimeoutRef = useRef(null); |
|
|
| |
| const [animateHeader, setAnimateHeader] = useState(false); |
| const [toast, setToast] = useState({ visible: false, title: "", msg: "" }); |
|
|
| |
| const effectiveDefaultBranch = defaultBranch || repo?.default_branch || "main"; |
| const branch = currentBranch || effectiveDefaultBranch; |
|
|
| |
| const isAiSession = (sessionBranches.includes(branch)) || (sessionBranch === branch && branch !== effectiveDefaultBranch); |
|
|
| |
| 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)); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (!pulseNonce) return; |
| setAnimateHeader(true); |
| const t = window.setTimeout(() => setAnimateHeader(false), 1100); |
| return () => window.clearTimeout(t); |
| }, [pulseNonce]); |
|
|
| |
| |
| useEffect(() => { |
| if (!repo) return; |
|
|
| |
| 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}`; |
|
|
| |
| 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); |
|
|
| |
| 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" }); |
| }); |
|
|
| |
| |
| 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); |
| }; |
| |
| }, [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; |
| } |
|
|
| |
| 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"); |
| }; |
|
|
| |
| 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] |
| ); |
|
|
| |
| 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 ( |
| <div style={styles.container}> |
| <div style={styles.content}>Select a Repo</div> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div style={styles.container}> |
| <style>{animationStyles}</style> |
| |
| {/* TOAST */} |
| <div style={styles.toast}> |
| <div style={styles.toastTitle}>{toast.title}</div> |
| <div style={styles.toastMsg}>{toast.msg}</div> |
| </div> |
| |
| {/* HEADER */} |
| <div style={styles.header} className={animateHeader ? "pulse-context" : ""}> |
| <div style={styles.titleGroup}> |
| <span style={styles.title}>Project context</span> |
| {isAiSession && ( |
| <span style={styles.aiBadge}> |
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> |
| <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" /> |
| </svg> |
| AI Session |
| </span> |
| )} |
| </div> |
| <div style={{ display: "flex", alignItems: "center", gap: "6px" }}> |
| {!isAiSession && <span style={styles.repoBadge}>{repo.name}</span>} |
| {onSettingsClick && ( |
| <button |
| type="button" |
| onClick={onSettingsClick} |
| title="Project settings" |
| style={styles.settingsBtn} |
| > |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
| <circle cx="12" cy="12" r="3" /> |
| <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> |
| </svg> |
| </button> |
| )} |
| </div> |
| </div> |
| |
| {/* CONTENT */} |
| <div style={styles.content}> |
| {/* Branch selector (Claude-Code-on-Web parity — uses BranchPicker with search) */} |
| <div style={styles.statRow}> |
| <span style={styles.label}>Branch:</span> |
| <BranchPicker |
| repo={repo} |
| currentBranch={branch} |
| defaultBranch={effectiveDefaultBranch} |
| sessionBranches={sessionBranches} |
| onBranchChange={handleManualSwitch} |
| /> |
| </div> |
| |
| {/* Stats */} |
| <div style={styles.statRow}> |
| <span style={styles.label}>Files:</span> |
| <span style={styles.value}>{analyzing ? "…" : fileCount}</span> |
| </div> |
| |
| <div style={styles.statRow}> |
| <span style={styles.label}>Status:</span> |
| <span style={{ ...styles.value, color: statusColor }}>{statusText}</span> |
| </div> |
| |
| {/* Tree error (optional display) */} |
| {treeError && ( |
| <div style={{ fontSize: 11, color: theme.warningText }}> |
| {treeError} |
| </div> |
| )} |
| |
| {/* Refresh */} |
| <button type="button" style={styles.refreshButton} onClick={handleRefresh} disabled={analyzing}> |
| <svg |
| width="14" |
| height="14" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth="2" |
| style={{ |
| transform: analyzing ? "rotate(360deg)" : "rotate(0deg)", |
| transition: "transform 0.6s ease", |
| }} |
| > |
| <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" /> |
| </svg> |
| {analyzing ? "Refreshing..." : "Refresh"} |
| </button> |
| |
| {/* Install card */} |
| {showInstallCard && ( |
| <div style={styles.installCard}> |
| <div style={styles.installHeader}> |
| <span>⚡</span> |
| <span>Enable Write Access</span> |
| </div> |
| <p style={{ ...styles.installText, margin: "8px 0" }}> |
| Install the GitPilot App to enable AI agent operations. |
| </p> |
| <button |
| type="button" |
| style={{ |
| ...styles.refreshButton, |
| width: "100%", |
| backgroundColor: theme.accent, |
| color: "#fff", |
| border: "none", |
| }} |
| onClick={handleInstallClick} |
| > |
| Install App |
| </button> |
| </div> |
| )} |
| </div> |
| |
| {/* Context banner */} |
| {isAiSession && ( |
| <div style={styles.contextBanner}> |
| <span style={{ display: "flex", alignItems: "center", gap: "6px" }}> |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
| <circle cx="12" cy="12" r="10"></circle> |
| <line x1="12" y1="16" x2="12" y2="12"></line> |
| <line x1="12" y1="8" x2="12.01" y2="8"></line> |
| </svg> |
| You are viewing an AI Session branch. |
| </span> |
| <span style={{ textDecoration: "underline", cursor: "pointer" }} onClick={() => handleManualSwitch(effectiveDefaultBranch)}> |
| Return to {effectiveDefaultBranch} |
| </span> |
| </div> |
| )} |
| |
| {/* File tree (branch-aware) */} |
| <div style={styles.treeWrapper}> |
| <FileTree repo={repo} refreshTrigger={refreshTrigger} branch={branch} /> |
| </div> |
| </div> |
| ); |
| } |