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