// frontend/components/FilePreviewPanel.jsx
//
// Read-first file viewer with two density modes.
//
// mode="preview" Narrow right-side drawer for a quick look.
// ~520px wide, primary CTA "Prepare Run".
// mode="workspace" Wide right-side editor for serious review.
// ~85vw, same actions, much more reading room.
//
// Header vocabulary follows the enterprise verbs the design review
// nailed down:
//
// [▶ Prepare Run] only on runnable files (hidden on README etc.)
// [Open Workspace] upgrades preview → workspace (or back)
// [⋯] overflow: Ask GitPilot · Open Canvas ·
// Copy path · Copy contents
//
// "Prepare Run" never runs anything directly — it dispatches the
// gitpilot:run-file event which lands on the green ExecutionPlanCard
// in chat, where the user approves before the sandbox starts.
//
// Error state: when the file content fetch fails we hide every action
// that depends on having content (Prepare Run / Canvas / Ask). Only
// safe actions remain (Retry / Copy path).
import React, { useEffect, useMemo, useRef, useState } from "react";
const RUNNABLE_FILE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]);
const LANG_LABEL = {
py: "Python", js: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
sh: "Shell", bash: "Shell",
md: "Markdown", json: "JSON", yml: "YAML", yaml: "YAML", toml: "TOML",
html: "HTML", css: "CSS", ts: "TypeScript", tsx: "TypeScript",
rs: "Rust", go: "Go", java: "Java", c: "C", cpp: "C++", h: "C/C++",
};
function extOf(path) {
if (!path || !path.includes(".")) return "";
return path.split(".").pop().toLowerCase();
}
function bytesPretty(n) {
if (n == null) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
// ---------------------------------------------------------------------------
// Overflow menu — secondary actions live here so the header stays calm.
// ---------------------------------------------------------------------------
function OverflowMenu({ path, content, runnable, onClose }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
const onDown = () => onClose?.();
window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousedown", onDown);
};
}, [onClose]);
const copy = (text) => {
if (navigator?.clipboard && text != null) navigator.clipboard.writeText(text).catch(() => {});
};
const items = [
{ label: "Ask GitPilot",
onClick: () => window.dispatchEvent(new CustomEvent("gitpilot:ask-about-file", { detail: { path } })) },
{ label: "Open in Canvas", runnable: true,
onClick: () => window.dispatchEvent(new CustomEvent("gitpilot:open-in-canvas", { detail: { path } })) },
{ divider: true },
{ label: "Copy path", onClick: () => copy(path) },
{ label: "Copy contents", onClick: () => copy(content), disabled: !content },
];
return (
e.stopPropagation()}
style={{
position: "absolute", right: 6, top: 36, zIndex: 50,
minWidth: 200,
background: "#18181B", border: "1px solid #3F3F46",
borderRadius: 6, padding: "4px 0",
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
}}>
{items.map((it, i) => {
if (it.divider) return
;
if (it.runnable && !runnable) return null;
return (
);
})}
);
}
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
export default function FilePreviewPanel({
path,
content,
loading,
error,
errorCode, // numeric HTTP status when known (e.g. 404)
notFoundKind, // "deleted" | "syncing" | "unavailable"
mode = "preview", // "preview" | "workspace"
branch,
onModeChange,
onClose,
onRetry,
onRefreshTree,
}) {
const ext = extOf(path);
const lang = LANG_LABEL[ext] || ext.toUpperCase() || "Text";
const runnable = RUNNABLE_FILE_EXTS.has(ext);
const size = content ? new Blob([content]).size : null;
const filename = path ? path.split("/").pop() : "";
const [menuOpen, setMenuOpen] = useState(false);
const menuBtnRef = useRef(null);
// Esc closes — keyboard contract every modal/drawer in GitPilot uses.
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const prepareRun = () => {
window.dispatchEvent(new CustomEvent("gitpilot:run-file", { detail: { path } }));
onClose?.();
};
const toggleWorkspace = () => {
if (onModeChange) onModeChange(mode === "workspace" ? "preview" : "workspace");
};
const lines = useMemo(() => (content || "").split("\n"), [content]);
// Width contract: preview = peek, workspace = serious reading.
const widthCss = mode === "workspace"
? "min(1280px, 86vw)"
: "min(520px, 44vw)";
// Decide which actions to show. Two rules:
// 1. While loading, secondary actions exist but disabled.
// 2. On error, hide every action that requires content; we show
// Retry + Copy path only.
const showActions = !error;
return (
);
}
// ---------------------------------------------------------------------------
// Calm empty state — replaces the harsh red "Couldn't load this file"
// box. Same component, classifies the situation into one of:
// deleted → execution removed the file from this branch
// syncing → execution created the file but content isn't ready yet
// unavailable → file simply isn't on this branch
// ---------------------------------------------------------------------------
function NotAvailableCard({ path, branch, error, errorCode, kind, onRetry, onRefreshTree }) {
const isNotFound =
errorCode === 404 ||
/not\s*found/i.test(String(error || "")) ||
/HTTP\s*404/i.test(String(error || ""));
const effectiveKind = isNotFound ? (kind || "unavailable") : "error";
const copy = {
deleted: {
icon: "🗑",
title: "File deleted",
body: `${path ? path.split("/").pop() : "This file"} was removed by the latest execution. View the execution summary for details.`,
tone: "muted",
},
syncing: {
icon: "↻",
title: "File still syncing",
body: "GitPilot just created this file. Its content is being published to the active branch — try again in a moment.",
tone: "info",
},
unavailable: {
icon: "○",
title: "File unavailable",
body: "This file isn't available on the current branch. It may have been moved, renamed, or never committed here.",
tone: "muted",
},
error: {
icon: "!",
title: "Couldn't load this file",
body: "Something went wrong fetching the file content. Try again, or refresh the file tree.",
tone: "warn",
},
}[effectiveKind];
return (
{copy.icon}
{copy.title}
{copy.body}
- Path
{path}
{branch && (
<>
- Branch
{branch}
>
)}
{onRefreshTree && (
)}
{effectiveKind === "error" && error && (
Technical details
{String(error)}
)}
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const s = {
shell: {
position: "fixed",
top: 0, right: 0, bottom: 0,
// ``width`` is set per-mode in the component.
background: "#0d0e17",
borderLeft: "1px solid #2a2b36",
display: "flex", flexDirection: "column",
zIndex: 90,
color: "#e4e4e7",
fontFamily: "system-ui, sans-serif",
boxShadow: "-12px 0 32px rgba(0,0,0,0.45)",
transition: "width 0.15s ease",
},
header: {
padding: "12px 16px 10px",
borderBottom: "1px solid #2a2b36",
background: "#14152a",
},
titleRow: { display: "flex", justifyContent: "space-between", gap: 12 },
filename: {
fontSize: 16, fontWeight: 600,
fontFamily: "ui-monospace, monospace",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
},
subpath: {
fontSize: 11, color: "#9092b5", marginTop: 2,
fontFamily: "ui-monospace, monospace",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
},
dot: { margin: "0 6px", color: "#52525B" },
metaPills: { display: "flex", alignItems: "flex-start", gap: 6, flexShrink: 0 },
pill: {
fontSize: 11, padding: "2px 8px", borderRadius: 4,
background: "#1e3a5f", color: "#93c5fd",
border: "1px solid #3B82F6",
},
pillDim: {
fontSize: 11, padding: "2px 8px", borderRadius: 4,
background: "#0d0e17", color: "#9092b5",
border: "1px solid #2c2d46",
},
actions: { display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap", alignItems: "center" },
btnPrimary: {
padding: "6px 14px", fontSize: 12, fontWeight: 600,
background: "#10B981", color: "#052e1c",
border: "0", borderRadius: 6, cursor: "pointer",
},
btnSecondary: {
padding: "6px 12px", fontSize: 12,
background: "transparent", color: "#c3c5dd",
border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer",
},
btnClose: {
marginLeft: 6,
padding: "2px 8px", fontSize: 14,
background: "transparent", color: "#9092b5",
border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer",
},
body: { flex: 1, overflow: "auto", padding: "12px 0" },
empty: { padding: 30, textAlign: "center", color: "#9092b5", fontSize: 13 },
code: {
margin: 0, padding: 0,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12.5, lineHeight: 1.55,
color: "#D4D4D8",
background: "transparent",
whiteSpace: "pre",
},
lineRow: { display: "grid", gridTemplateColumns: "48px 1fr", gap: 12, padding: "0 18px" },
lineNo: {
color: "#52525B", textAlign: "right",
userSelect: "none",
fontVariantNumeric: "tabular-nums",
},
lineCode: { whiteSpace: "pre-wrap", wordBreak: "break-word" },
};