import React, { useState, useEffect } from "react"; /** * Simple recursive file tree viewer with refresh support * Fetches tree data directly using the API. */ export default function FileTree({ repo, refreshTrigger, branch }) { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [isSwitchingBranch, setIsSwitchingBranch] = useState(false); const [error, setError] = useState(null); const [localRefresh, setLocalRefresh] = useState(0); // Search query for the in-sidebar filter. Empty string == no filter. // Filter runs case-insensitively on path basenames *and* full paths // so users can pinpoint a file even in deeply nested folders. const [query, setQuery] = useState(""); // Which file is currently focused in the preview panel β€” populated // by the ``gitpilot:file-opened`` event ChatPanel emits when the // preview finishes loading. Drives the β—„ marker + active row tint. const [selectedPath, setSelectedPath] = useState(null); useEffect(() => { const onOpened = (e) => setSelectedPath(e?.detail?.path || null); const onClosed = () => setSelectedPath(null); const onRefresh = () => setLocalRefresh((n) => n + 1); window.addEventListener("gitpilot:file-opened", onOpened); window.addEventListener("gitpilot:file-closed", onClosed); window.addEventListener("gitpilot:refresh-tree", onRefresh); return () => { window.removeEventListener("gitpilot:file-opened", onOpened); window.removeEventListener("gitpilot:file-closed", onClosed); window.removeEventListener("gitpilot:refresh-tree", onRefresh); }; }, []); useEffect(() => { if (!repo) return; // Determine if this is a branch switch (we already have data) const hasExistingData = tree.length > 0; if (hasExistingData) { setIsSwitchingBranch(true); } else { setLoading(true); } setError(null); // Construct headers manually 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); } // Add cache busting + selected branch ref const refParam = branch ? `&ref=${encodeURIComponent(branch)}` : ""; const cacheBuster = `?_t=${Date.now()}${refParam}`; let cancelled = false; fetch(`/api/repos/${repo.owner}/${repo.name}/tree${cacheBuster}`, { headers }) .then(async (res) => { if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.detail || "Failed to load files"); } return res.json(); }) .then((data) => { if (cancelled) return; if (data.files && Array.isArray(data.files)) { setTree(buildTree(data.files)); setError(null); } else { setError("No files found in repository"); } }) .catch((err) => { if (cancelled) return; setError(err.message); console.error("FileTree error:", err); }) .finally(() => { if (cancelled) return; setIsSwitchingBranch(false); setLoading(false); }); return () => { cancelled = true; }; }, [repo, branch, refreshTrigger, localRefresh]); // eslint-disable-line react-hooks/exhaustive-deps const handleRefresh = () => { setLocalRefresh(prev => prev + 1); }; // Theme matching parent component const theme = { border: "#27272A", textPrimary: "#EDEDED", textSecondary: "#A1A1AA", accent: "#D95C3D", warningText: "#F59E0B", warningBg: "rgba(245, 158, 11, 0.1)", warningBorder: "rgba(245, 158, 11, 0.2)", }; const styles = { header: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 20px 8px 10px", marginBottom: "8px", borderBottom: `1px solid ${theme.border}`, }, headerTitle: { fontSize: "12px", fontWeight: "600", color: theme.textSecondary, textTransform: "uppercase", letterSpacing: "0.5px", }, refreshButton: { backgroundColor: "transparent", border: `1px solid ${theme.border}`, color: theme.textSecondary, padding: "4px 8px", borderRadius: "4px", fontSize: "11px", cursor: loading ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: "4px", transition: "all 0.2s", opacity: loading ? 0.5 : 1, }, switchingBar: { padding: "6px 20px", fontSize: "11px", color: theme.textSecondary, backgroundColor: "rgba(59, 130, 246, 0.06)", borderBottom: `1px solid ${theme.border}`, }, loadingText: { padding: "0 20px", color: theme.textSecondary, fontSize: "13px", }, errorBox: { padding: "12px 20px", color: theme.warningText, fontSize: "12px", backgroundColor: theme.warningBg, border: `1px solid ${theme.warningBorder}`, borderRadius: "6px", margin: "0 10px", }, emptyText: { padding: "0 20px", color: theme.textSecondary, fontSize: "13px", }, treeContainer: { fontSize: "13px", color: theme.textSecondary, padding: "0 10px 20px 10px", }, }; return (
{/* Header with Refresh Button */}
Files
{/* Branch switch indicator (shown above existing tree, doesn't clear it) */} {isSwitchingBranch && (
Loading branch...
)} {/* Content */} {loading && tree.length === 0 && (
Loading files...
)} {!loading && !isSwitchingBranch && error && (
{error}
)} {!loading && !isSwitchingBranch && !error && tree.length === 0 && (
No files found
)} {tree.length > 0 && ( <> {/* In-sidebar file search. Enterprise repos run to hundreds of files; the explorer needs a quick narrowing affordance before any of the contextual menus matter. */} setQuery(e.target.value)} placeholder="πŸ” Search files…" spellCheck={false} style={{ width: "100%", boxSizing: "border-box", margin: "4px 0 8px", padding: "6px 10px", fontSize: 12, background: "#0d0e17", color: "#e4e4e7", border: "1px solid #27272A", borderRadius: 6, outline: "none", fontFamily: "system-ui, sans-serif", }} />
{tree.map((node) => ( ))}
)}
); } // Files with these extensions get a "Prepare Run" menu item. Mirrors // the backend's _RUNNABLE_EXTENSIONS so the menu only offers a run // where the sandbox planner would actually accept the file. const RUNNABLE_FILE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]); function isRunnableFile(name, type) { if (type === "tree") return false; if (!name || !name.includes(".")) return false; const ext = name.split(".").pop().toLowerCase(); return RUNNABLE_FILE_EXTS.has(ext); } // Window-event dispatch helpers β€” keep FileTree decoupled from // ChatPanel. Same pattern as the existing approval-flow events. function dispatchOpenFile(path) { try { window.dispatchEvent(new CustomEvent("gitpilot:open-file", { detail: { path } })); } catch (_e) { /* old browser */ } } function dispatchOpenInCanvas(path) { try { window.dispatchEvent(new CustomEvent("gitpilot:open-in-canvas", { detail: { path } })); } catch (_e) { /* old browser */ } } function dispatchRunFile(path) { try { window.dispatchEvent(new CustomEvent("gitpilot:run-file", { detail: { path } })); } catch (_e) { /* old browser */ } } function dispatchAskAboutFile(path) { try { window.dispatchEvent(new CustomEvent("gitpilot:ask-about-file", { detail: { path } })); } catch (_e) { /* old browser */ } } function copyToClipboard(text) { if (navigator?.clipboard) navigator.clipboard.writeText(text).catch(() => {}); } // Tiny dropdown β€” positioned absolutely below the row's β‹― trigger. // Closes on outside click or Escape. Built-in