gitpilot / frontend /components /SandboxCanvas.jsx
github-actions[bot]
Deploy from 53884f28
6078519
Raw
History Blame Contribute Delete
43.4 kB
// frontend/components/SandboxCanvas.jsx
//
// File-aware Canvas: a modal that adapts to the file type it was
// opened with. Three modes:
//
// code → split editor + sandbox output. Prepare-Run flow.
// Used for .py / .js / .sh / inline snippets.
// markdown → split source + rendered preview. No Run button.
// Used for .md / .markdown.
// text → source-only viewer (read-only by default).
// Used for everything else: .json, .yml, .csv,
// .html, .txt, ...
//
// Mode is computed from the ``filename`` prop's extension. When
// ``filename`` is null (an anonymous inline snippet from a chat code
// block) we fall back to code mode using the ``initialLanguage`` tag.
//
// The sandbox approval contract is unchanged: Prepare Run dispatches
// /api/sandbox/plan, the user approves a green ExecutionPlanCard,
// only then /api/sandbox/run actually executes.
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiUrl } from "../utils/api.js";
import { fetchExecutionPlan } from "./ExecutionPlanCard.jsx";
const BACKEND_LABELS = {
subprocess: "Local",
matrixlab: "MatrixLab",
off: "Pass-through",
};
const LANG_DISPLAY = {
py: "python", js: "javascript", node: "javascript",
sh: "bash", shell: "bash",
};
// Extensions that route through the sandbox. Everything else gets
// a viewer-only canvas — README.md must NOT show a Python execution
// plan, JSON must NOT pretend it can run.
const RUNNABLE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]);
const MARKDOWN_EXTS = new Set(["md", "markdown", "mdx"]);
// Map extension → canonical sandbox language tag used by the planner.
const EXT_TO_LANG = {
py: "python", js: "javascript", mjs: "javascript", cjs: "javascript",
sh: "bash", bash: "bash",
};
// Display label per file type — keeps the header honest about what
// the Canvas is showing.
const TYPE_LABEL = {
python: "Python", javascript: "JavaScript", bash: "Shell",
markdown: "Markdown", json: "JSON", yaml: "YAML", csv: "CSV",
html: "HTML", text: "Text",
};
function extOf(name) {
if (!name || !name.includes(".")) return "";
return name.split(".").pop().toLowerCase();
}
// Decide which Canvas branch to render based on the opening context.
// Pure function so tests can pin it.
function resolveCanvasMode({ filename, initialLanguage }) {
const ext = extOf(filename || "");
if (filename && MARKDOWN_EXTS.has(ext)) {
return { kind: "markdown", typeLabel: "Markdown", language: "markdown" };
}
if (filename && RUNNABLE_EXTS.has(ext)) {
const lang = EXT_TO_LANG[ext];
return { kind: "code", typeLabel: TYPE_LABEL[lang], language: lang };
}
if (filename) {
// Known-but-non-runnable: json / yaml / csv / html / txt / etc.
const known = { json: "json", yml: "yaml", yaml: "yaml",
csv: "csv", html: "html", txt: "text" }[ext];
if (known) {
return { kind: "text", typeLabel: TYPE_LABEL[known], language: known };
}
return { kind: "text", typeLabel: ext.toUpperCase() || "Text", language: "text" };
}
// No filename → inline snippet from a chat code block. Honor the
// language tag the chat fence carried.
const tag = (initialLanguage || "python").toLowerCase();
if (tag === "py" || tag === "python") return { kind: "code", typeLabel: "Python snippet", language: "python" };
if (tag === "js" || tag === "javascript" || tag === "node")
return { kind: "code", typeLabel: "JavaScript snippet", language: "javascript" };
if (tag === "sh" || tag === "bash" || tag === "shell")
return { kind: "code", typeLabel: "Shell snippet", language: "bash" };
return { kind: "text", typeLabel: "Text", language: "text" };
}
// Minimal, dependency-free Markdown → HTML renderer. Covers the
// patterns that matter for README files: headings, bold, italic,
// inline code, fenced code blocks, lists, links. Escapes HTML to
// avoid XSS when README contains <script> or similar.
function escapeHtml(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderMarkdown(src) {
if (!src) return "";
let s = escapeHtml(src);
// Fenced code blocks first so their content isn't re-processed.
s = s.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
`<pre class="md-code"><code>${code}</code></pre>`,
);
// Headings — process before line-based replacements.
s = s.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>");
s = s.replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>");
s = s.replace(/^####\s+(.+)$/gm, "<h4>$1</h4>");
s = s.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>");
s = s.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>");
s = s.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");
// Bold / italic / inline code.
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");
// Links: [text](url) — only allow http(s) + mailto + relative paths.
s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\/|\.)[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// Unordered list items.
s = s.replace(/^[ \t]*[-*]\s+(.+)$/gm, "<li>$1</li>");
s = s.replace(/(<li>[\s\S]*?<\/li>(?:\n<li>[\s\S]*?<\/li>)*)/g,
(m) => `<ul>${m.replace(/\n/g, "")}</ul>`);
// Paragraphs — blank-line separated runs that aren't already block-tagged.
s = s.split(/\n{2,}/).map((para) => {
if (/^<(h\d|ul|pre|p|blockquote)/.test(para.trim())) return para;
return `<p>${para.replace(/\n/g, " ")}</p>`;
}).join("\n");
return s;
}
// Mirrors the executor's _looks_like_matplotlib heuristic so plt.show()
// snippets don't hang the headless sandbox. False positives are
// harmless (Agg is a valid backend for any Python script); false
// negatives cause a hang on a plot window that never opens.
function looksLikeMatplotlib(code) {
if (!code) return false;
const lower = code.toLowerCase();
return /import\s+matplotlib|from\s+matplotlib|plt\.show|pyplot/.test(lower);
}
function applyMatplotlibShim(language, code) {
if (language !== "python" && language !== "py") return code;
if (!looksLikeMatplotlib(code)) return code;
return (
"import os as _gp_os\n" +
'_gp_os.environ.setdefault("MPLBACKEND", "Agg")\n' +
code
);
}
export default function SandboxCanvas(props) {
// Branch by mode at the top so non-runnable file types never see
// the sandbox plumbing. Each branch is a focused subcomponent —
// easier to read and easier to test than a single 400-line function
// with three conditional render paths.
const mode = resolveCanvasMode({
filename: props.filename,
initialLanguage: props.initialLanguage,
});
if (mode.kind === "markdown") return <MarkdownCanvas {...props} mode={mode} />;
if (mode.kind === "text") return <TextCanvas {...props} mode={mode} />;
return <CodeCanvas {...props} mode={mode} />;
}
// ---------------------------------------------------------------------------
// CodeCanvas — the original sandbox-enabled Canvas, now scoped to
// runnable code. Uses ``filename`` (when present and not "inline.*")
// as the ``display_filename`` on the ExecutionPlan so the command
// reads ``python demo.py`` instead of ``python inline.py``.
// ---------------------------------------------------------------------------
function CodeCanvas({
initialCode,
filename,
onClose,
onSaveAsFile,
mode,
}) {
const language = mode.language;
const [code, setCode] = useState(initialCode || "");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [startTime, setStartTime] = useState(null);
const [elapsed, setElapsed] = useState(0);
const tickRef = useRef(null);
// Approval-first: clicking Run fetches a deterministic plan first;
// the slim confirmation strip lets the user back out before any code
// actually executes in the sandbox.
const [pendingPlan, setPendingPlan] = useState(null);
const [planError, setPlanError] = useState(null);
const [showRunDetails, setShowRunDetails] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [finishedAt, setFinishedAt] = useState(null);
const [phase, setPhase] = useState("idle"); // idle | preparing | running | done | failed
const menuRef = useRef(null);
useEffect(() => {
if (!busy || !startTime) return;
tickRef.current = setInterval(() => {
setElapsed(Date.now() - startTime);
}, 100);
return () => {
if (tickRef.current) clearInterval(tickRef.current);
tickRef.current = null;
};
}, [busy, startTime]);
useEffect(() => {
if (!showMenu) return;
const onDown = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
setShowMenu(false);
}
};
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [showMenu]);
const requestPlan = useCallback(async () => {
setPlanError(null);
setError(null);
setBusy(true);
setPhase("preparing");
try {
const shipped = applyMatplotlibShim(language, code);
const isInlineName = (filename || "").toLowerCase().startsWith("inline.");
const displayFilename = filename && !isInlineName ? filename : undefined;
const plan = await fetchExecutionPlan({
code: shipped,
language,
source: "canvas",
display_filename: displayFilename,
});
setPendingPlan(plan);
} catch (err) {
setPlanError(err.message || "Could not build execution plan");
setPhase("failed");
} finally {
setBusy(false);
}
}, [language, code, filename]);
const approveRun = useCallback(async (plan) => {
setBusy(true);
setResult(null);
setError(null);
setStartTime(Date.now());
setElapsed(0);
setPhase("running");
try {
const res = await fetch(apiUrl("/api/sandbox/run"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
language: plan.language,
code: plan.inline_code,
timeout_sec: plan.timeout_sec,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.detail || `HTTP ${res.status}`);
setPhase("failed");
return;
}
setResult(data);
setPendingPlan(null);
setFinishedAt(Date.now());
setPhase(data.exit_code === 0 ? "done" : "failed");
} catch (err) {
setError(err.message || "Run failed");
setPhase("failed");
} finally {
setBusy(false);
}
}, []);
const run = requestPlan;
const cancelPending = () => { setPendingPlan(null); setPhase("idle"); };
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape" && !busy) onClose?.();
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") run();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [run, busy, onClose]);
const exitOk = result?.exit_code === 0;
const displayLang = LANG_DISPLAY[language] || language;
const headerFilename = filename
|| `inline.${displayLang === "javascript" ? "js" : displayLang === "bash" ? "sh" : "py"}`;
const isPython = language === "python";
const showMplNotice = isPython && looksLikeMatplotlib(code);
// Crude but useful: flag scripts that read env vars so users can spot
// sandbox-scoped variable scoping issues before they run.
const readsEnvVars = /os\.environ|process\.env|getenv/.test(code || "");
const runCommand = useMemo(() => {
const base = headerFilename.split("/").pop();
if (language === "python") return `python ${base}`;
if (language === "javascript") return `node ${base}`;
if (language === "bash") return `bash ${base}`;
return base;
}, [headerFilename, language]);
const lineCount = (code || "").split("\n").length;
const charCount = (code || "").length;
const statusText =
phase === "preparing" ? "Preparing sandbox…" :
phase === "running" ? `Running ${runCommand}…` :
phase === "done" ? `Completed in ${((finishedAt - startTime) / 1000).toFixed(1)}s` :
phase === "failed" ? "Execution failed" :
"Ready to run";
const statusTone =
phase === "preparing" || phase === "running" ? "info" :
phase === "done" ? "ok" :
phase === "failed" ? "bad" :
"ok";
return (
<div style={s.backdrop} onClick={onClose}>
<div className="canvas-modal" style={s.modal} onClick={(e) => e.stopPropagation()}>
<header className="canvas-header">
<div className="canvas-header__left">
<div className="canvas-header__glyph" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</div>
<div className="canvas-header__title">{headerFilename.split("/").pop()}</div>
<span className="canvas-pill canvas-pill--lang">
<span className="canvas-pill__dot" />
{mode.typeLabel}
</span>
<span className="canvas-pill canvas-pill--sandbox">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Local sandbox
</span>
</div>
<div className="canvas-header__right">
<button
type="button"
className="canvas-run-btn"
onClick={pendingPlan ? () => approveRun(pendingPlan) : requestPlan}
disabled={busy && phase !== "running"}
>
{phase === "running" ? (
<>
<span className="canvas-run-btn__spinner" />
Running {(elapsed / 1000).toFixed(1)}s
</>
) : phase === "preparing" ? (
"Preparing…"
) : pendingPlan ? (
<>▶ Confirm & run</>
) : (
<>▶ Run</>
)}
</button>
<div className="canvas-menu" ref={menuRef}>
<button
type="button"
className="canvas-icon-btn"
onClick={() => setShowMenu((v) => !v)}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={showMenu}
>
</button>
{showMenu && (
<div className="canvas-menu__panel" role="menu">
{onSaveAsFile && (
<button
type="button"
className="canvas-menu__item"
onClick={() => { setShowMenu(false); onSaveAsFile(code, language); }}
>
Save to repo…
</button>
)}
<button
type="button"
className="canvas-menu__item"
onClick={() => {
if (navigator?.clipboard) {
navigator.clipboard.writeText(code).catch(() => {});
}
setShowMenu(false);
}}
>
Copy source
</button>
<button
type="button"
className="canvas-menu__item"
onClick={() => { setShowMenu(false); onClose?.(); }}
>
Close canvas
</button>
</div>
)}
</div>
<button type="button" className="canvas-icon-btn canvas-icon-btn--close"
onClick={onClose} aria-label="Close">
</button>
</div>
</header>
<div className="canvas-status">
<div className={`canvas-status__dot canvas-status__dot--${statusTone}`} aria-hidden="true">
{phase === "done" ? "✓" :
phase === "failed" ? "✕" :
phase === "preparing" || phase === "running" ? "" :
"▸"}
</div>
<div className="canvas-status__text">
<div className="canvas-status__title">{statusText}</div>
<div className="canvas-status__sub">
<code>{runCommand}</code>
<span className="canvas-status__sep">·</span>
Timeout 120s
<span className="canvas-status__sep">·</span>
Network disabled
</div>
</div>
<button
type="button"
className="canvas-status__details"
onClick={() => setShowRunDetails((v) => !v)}
aria-expanded={showRunDetails}
>
Details {showRunDetails ? "▴" : "▾"}
</button>
</div>
{showRunDetails && (
<div className="canvas-run-config">
<div className="canvas-run-config__row">
<span className="canvas-run-config__label">Command</span>
<code className="canvas-run-config__val">{runCommand}</code>
</div>
<div className="canvas-run-config__row">
<span className="canvas-run-config__label">Environment</span>
<span className="canvas-run-config__val">Local sandbox (isolated)</span>
</div>
<div className="canvas-run-config__row">
<span className="canvas-run-config__label">Timeout</span>
<span className="canvas-run-config__val">120s</span>
</div>
<div className="canvas-run-config__row">
<span className="canvas-run-config__label">Network</span>
<span className="canvas-run-config__val">Disabled</span>
</div>
<div className="canvas-run-config__row">
<span className="canvas-run-config__label">Snippet</span>
<span className="canvas-run-config__val">{lineCount} lines · {charCount} chars</span>
</div>
</div>
)}
{(showMplNotice || readsEnvVars) && (
<div className="canvas-notices">
<div className="canvas-notices__label">Notices</div>
<div className="canvas-notices__row">
{readsEnvVars && (
<div className="canvas-notice canvas-notice--info">
<div className="canvas-notice__icon" aria-hidden="true"></div>
<div className="canvas-notice__body">
<div className="canvas-notice__title">Reads environment variables</div>
<div className="canvas-notice__desc">
Script reads env vars — only sandbox-scoped variables are present.
</div>
</div>
</div>
)}
{showMplNotice && (
<div className="canvas-notice canvas-notice--accent">
<div className="canvas-notice__icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<polyline points="3 17 9 11 13 15 21 7" />
</svg>
</div>
<div className="canvas-notice__body">
<div className="canvas-notice__title">Matplotlib detected</div>
<div className="canvas-notice__desc">
Plots will render inline in the sandbox output.
</div>
</div>
</div>
)}
</div>
</div>
)}
{pendingPlan && (
<div className="canvas-confirm">
<div className="canvas-confirm__text">
GitPilot will run <code>{runCommand}</code> in the local sandbox.
Click <strong>Confirm & run</strong> to proceed.
</div>
<button type="button" className="canvas-confirm__cancel" onClick={cancelPending}>
Cancel
</button>
</div>
)}
{planError && (
<div className="canvas-plan-error">Plan error: {planError}</div>
)}
<div className="canvas-body">
<div className="canvas-pane canvas-pane--source">
<div className="canvas-pane__head">
<div className="canvas-pane__title">Source</div>
<div className="canvas-pane__head-right">
<span className="canvas-pane__meta">{mode.typeLabel}</span>
<button
type="button"
className="canvas-pane__icon"
title="Copy source"
onClick={() => {
if (navigator?.clipboard) {
navigator.clipboard.writeText(code).catch(() => {});
}
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
<div className="canvas-editor-wrap">
<pre className="canvas-editor-gutter" aria-hidden="true">
{Array.from({ length: Math.max(lineCount, 1) }, (_, i) => (
<div key={i} className="canvas-editor-gutter__line">{i + 1}</div>
))}
</pre>
<textarea
className="canvas-editor"
value={code}
onChange={(e) => setCode(e.target.value)}
spellCheck={false}
/>
</div>
<div className="canvas-statusbar">
<span>{lineCount} lines</span>
<span className="canvas-statusbar__sep">·</span>
<span>{mode.typeLabel}</span>
<span className="canvas-statusbar__spacer" />
<span className="canvas-statusbar__kbd">⌘/Ctrl+Enter to run · Esc to close</span>
</div>
</div>
<div className="canvas-pane canvas-pane--output">
<div className="canvas-pane__head">
<div className="canvas-pane__title">Sandbox output</div>
{result && (
<button
type="button"
className="canvas-pane__icon-btn"
title="Clear output"
onClick={() => { setResult(null); setError(null); setPhase("idle"); }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
Clear
</button>
)}
</div>
<div className="canvas-output-body">
{!result && !error && phase === "idle" && (
<div className="canvas-output-empty">
<div className="canvas-output-empty__title">Ready to run</div>
<div className="canvas-output-empty__sub">
Click <strong>▶ Run</strong> to execute this snippet in the local sandbox.
</div>
</div>
)}
{(phase === "preparing" || phase === "running") && (
<div className="canvas-output-running">
<span className="canvas-output-running__spinner" aria-hidden="true" />
<span>
{phase === "preparing" ? "Preparing sandbox…" : `Running… ${(elapsed / 1000).toFixed(1)}s`}
</span>
</div>
)}
{error && phase === "failed" && (
<div className="canvas-result-head canvas-result-head--bad">
<span className="canvas-result-icon"></span>
<div>
<div className="canvas-result-title">Execution failed</div>
<pre className="canvas-result-err">{error}</pre>
</div>
</div>
)}
{result && (
<>
<div className={`canvas-result-head canvas-result-head--${exitOk ? "ok" : "bad"}`}>
<span className="canvas-result-icon">{exitOk ? "✓" : "✕"}</span>
<div className="canvas-result-headtext">
<div className="canvas-result-title">
{exitOk ? `Completed in ${((finishedAt - startTime) / 1000).toFixed(1)}s` : `Failed (exit ${result.exit_code})`}
</div>
<div className="canvas-result-sub">
{BACKEND_LABELS[result.backend] || result.backend}
{typeof result.duration_ms === "number" && ` · ${result.duration_ms} ms`}
{result.timed_out && " · timed out"}
{result.truncated && " · truncated"}
</div>
</div>
<span className="canvas-result-time">
{new Date(finishedAt || Date.now()).toLocaleTimeString([], {
hour: "numeric", minute: "2-digit",
})}
</span>
</div>
{result.stdout && (
<div className="canvas-output-section">
<div className="canvas-output-section__label">
<span>STDOUT</span>
</div>
<pre className="canvas-output-pre">{result.stdout}</pre>
</div>
)}
{Array.isArray(result.artifacts) && result.artifacts.length > 0 && (
<div className="canvas-output-section">
<div className="canvas-output-section__label">
Artifacts <span className="canvas-output-section__count">{result.artifacts.length}</span>
</div>
<ul className="canvas-artifacts">
{result.artifacts.map((a, i) => (
<ArtifactItem key={i} artifact={a} />
))}
</ul>
</div>
)}
{result.stderr && (
<div className="canvas-output-section">
<div className="canvas-output-section__label canvas-output-section__label--err">
STDERR
</div>
<pre className="canvas-output-pre canvas-output-pre--err">{result.stderr}</pre>
</div>
)}
{!result.stdout && !result.stderr && (!result.artifacts || result.artifacts.length === 0) && (
<div className="canvas-output-empty">
<div className="canvas-output-empty__sub">
{exitOk
? "The script completed without producing any output."
: "The script failed without producing any output."}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function ArtifactItem({ artifact }) {
const name = artifact.name || artifact.id || "artifact";
const isImage =
(artifact.mime && artifact.mime.startsWith("image/")) ||
/\.(png|jpg|jpeg|gif|svg|webp)$/i.test(name);
const src =
artifact.data_url ||
artifact.url ||
(artifact.content_b64 && artifact.mime
? `data:${artifact.mime};base64,${artifact.content_b64}`
: null);
return (
<li className="canvas-artifact">
{isImage && src && (
<img src={src} alt={name} className="canvas-artifact__img" />
)}
<div className="canvas-artifact__row">
<div className="canvas-artifact__icon" aria-hidden="true">
{isImage ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
</div>
<code className="canvas-artifact__name">{name}</code>
{isImage && src && (
<span className="canvas-artifact__badge">Plot rendered inline</span>
)}
{artifact.size != null && (
<span className="canvas-artifact__meta">
{typeof artifact.size === "number" ? `${artifact.size} bytes` : artifact.size}
</span>
)}
{src && (
<a
href={src}
download={name}
className="canvas-artifact__download"
title="Download artifact"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
)}
</div>
</li>
);
}
// ---------------------------------------------------------------------------
// MarkdownCanvas — split source / rendered preview. No Run button,
// no execution plan. Mode switch lets users pick Source / Preview /
// Split. Renderer is dependency-free (escapes HTML, then formats
// the patterns README files actually use).
// ---------------------------------------------------------------------------
function MarkdownCanvas({ initialCode, filename, onClose, mode }) {
const [view, setView] = useState("split"); // "source" | "preview" | "split"
const html = useMemo(() => renderMarkdown(initialCode || ""), [initialCode]);
// Inject the rendered-markdown stylesheet once. Cheaper than a
// styled-component dance and keeps the renderer dependency-free.
useEffect(() => {
const id = "gitpilot-md-style";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
.gp-md h1 { font-size: 22px; margin: 18px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #2c2d46; color: #f4f4f5; }
.gp-md h2 { font-size: 18px; margin: 16px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #1f2937; color: #f4f4f5; }
.gp-md h3 { font-size: 15px; margin: 14px 0 4px; color: #f4f4f5; }
.gp-md h4, .gp-md h5, .gp-md h6 { font-size: 13px; margin: 12px 0 4px; color: #e4e4e7; }
.gp-md p { margin: 8px 0; color: #d4d4d8; }
.gp-md ul { margin: 6px 0 6px 18px; padding: 0; color: #d4d4d8; }
.gp-md li { margin: 3px 0; }
.gp-md a { color: #93c5fd; }
.gp-md code { background: #000; color: #86efac; padding: 1px 5px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12.5px; }
.gp-md pre.md-code { background: #000; color: #d4d4d8; padding: 10px 12px; border-radius: 6px; overflow: auto; }
.gp-md pre.md-code code { background: transparent; color: inherit; padding: 0; }
.gp-md strong { color: #f4f4f5; }
`;
document.head.appendChild(style);
}, []);
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const copy = () => {
if (navigator?.clipboard) navigator.clipboard.writeText(initialCode || "").catch(() => {});
};
return (
<div style={s.backdrop} onClick={onClose}>
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
<header style={s.header}>
<div style={s.headerLeft}>
<span style={s.canvasBadge}>Canvas</span>
<span style={s.filename}>{filename || "untitled.md"}</span>
<span style={s.langPill}>{mode.typeLabel}</span>
</div>
<div style={s.headerRight}>
{/* Mode switch — Source / Preview / Split. Default split
because users often want to compare raw markdown to
rendered output side-by-side. */}
<div style={s.segGroup} role="tablist">
{["source", "preview", "split"].map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={view === v}
onClick={() => setView(v)}
style={view === v ? s.segActive : s.seg}
>
{v[0].toUpperCase() + v.slice(1)}
</button>
))}
</div>
<button type="button" style={s.btn} onClick={copy}>
Copy
</button>
<button type="button" style={s.btnClose} onClick={onClose} aria-label="Close">
</button>
</div>
</header>
<div style={view === "split" ? s.body : s.bodySingle}>
{(view === "source" || view === "split") && (
<div style={s.editorPane}>
<div style={s.paneLabel}>Markdown source</div>
<pre style={s.mdSource}>{initialCode || ""}</pre>
</div>
)}
{(view === "preview" || view === "split") && (
<div style={s.outputPane}>
<div style={s.paneLabel}>Rendered preview</div>
<div
className="gp-md"
style={s.mdRendered}
// Renderer escapes HTML before formatting, so the
// injected string is safe. No <script>, no event
// handlers slip through.
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// TextCanvas — viewer-only Canvas for non-runnable, non-markdown
// file types (JSON, YAML, CSV, HTML, plain text, ...). No Run, no
// plan, no markdown rendering. Useful when a user clicks Open in
// Canvas on, say, a config file just to read it.
// ---------------------------------------------------------------------------
function TextCanvas({ initialCode, filename, onClose, mode }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const copy = () => {
if (navigator?.clipboard) navigator.clipboard.writeText(initialCode || "").catch(() => {});
};
return (
<div style={s.backdrop} onClick={onClose}>
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
<header style={s.header}>
<div style={s.headerLeft}>
<span style={s.canvasBadge}>Canvas</span>
<span style={s.filename}>{filename || "untitled"}</span>
<span style={s.langPill}>{mode.typeLabel}</span>
</div>
<div style={s.headerRight}>
<button type="button" style={s.btn} onClick={copy}>
Copy
</button>
<button type="button" style={s.btnClose} onClick={onClose} aria-label="Close">
</button>
</div>
</header>
<div style={s.bodySingle}>
<div style={s.editorPane}>
<div style={s.paneLabel}>Source</div>
<pre style={s.mdSource}>{initialCode || ""}</pre>
</div>
</div>
</div>
</div>
);
}
const s = {
backdrop: {
position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)",
display: "flex", alignItems: "center", justifyContent: "center",
zIndex: 100,
},
modal: {
width: "min(1200px, 96vw)", height: "min(800px, 92vh)",
background: "#1a1b26", border: "1px solid #2a2b36", borderRadius: 10,
display: "flex", flexDirection: "column",
color: "#e6e8ff", overflow: "hidden",
},
header: {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 14px", borderBottom: "1px solid #2a2b36",
background: "#14152a",
},
headerLeft: { display: "flex", alignItems: "center", gap: 10 },
headerRight: { display: "flex", alignItems: "center", gap: 8 },
canvasBadge: {
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
padding: "2px 8px", borderRadius: 4,
background: "#1e3a5f", color: "#93c5fd", textTransform: "uppercase",
},
filename: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
langPill: {
fontSize: 11, color: "#9092b5",
padding: "1px 6px", borderRadius: 4, background: "#0e0f24",
border: "1px solid #2c2d46",
},
btn: {
padding: "6px 12px", fontSize: 12, fontWeight: 600,
background: "#3B82F6", color: "#fff",
border: "none", borderRadius: 6, cursor: "pointer",
},
btnClose: {
padding: "4px 10px", fontSize: 14,
background: "transparent", color: "#9092b5",
border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer",
},
body: { flex: 1, display: "grid", gridTemplateColumns: "1fr 1fr", overflow: "hidden" },
editorPane: {
display: "flex", flexDirection: "column",
borderRight: "1px solid #2a2b36", padding: 12, gap: 6,
},
outputPane: {
display: "flex", flexDirection: "column",
padding: 12, gap: 6, overflow: "auto",
},
paneLabel: { fontSize: 11, color: "#9092b5", textTransform: "uppercase", letterSpacing: "0.06em" },
editor: {
flex: 1, resize: "none",
background: "#0d0e17", color: "#e6e8ff",
border: "1px solid #2c2d46", borderRadius: 6, padding: 10,
fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 13, lineHeight: 1.5,
},
editorFoot: { display: "flex", justifyContent: "space-between", fontSize: 11, color: "#9092b5" },
shimHint: { color: "#86efac" },
kbdHint: { color: "#6b7280" },
empty: { color: "#9092b5", fontSize: 13, padding: "20px 8px", textAlign: "center" },
resultHead: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 },
outputSection: { marginTop: 6 },
outputLabel: { cursor: "pointer", fontSize: 11, color: "#9092b5" },
stdout: {
margin: "4px 0 0", padding: "8px 10px", borderRadius: 4,
background: "#000", color: "#d4d4d8", fontSize: 12,
fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap",
},
stderr: {
margin: "4px 0 0", padding: "8px 10px", borderRadius: 4,
background: "#000", color: "#fca5a5", fontSize: 12,
fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap",
},
errorBox: {
padding: "8px 10px", borderRadius: 6,
background: "#3d1111", border: "1px solid #7f1d1d", color: "#fca5a5", fontSize: 12,
},
errorPre: { margin: "4px 0 0", whiteSpace: "pre-wrap" },
okPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600, background: "#0d3320", color: "#86efac", border: "1px solid #166534" },
failPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600, background: "#3d1111", color: "#fca5a5", border: "1px solid #7f1d1d" },
warnPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600, background: "#3d2d11", color: "#fde68a", border: "1px solid #854d0e" },
backendPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, color: "#c3c5dd", border: "1px solid #2c2d46" },
dim: { fontSize: 11, color: "#9092b5" },
artifactList: { listStyle: "none", padding: 0, margin: "6px 0 0" },
artifactItem: { padding: "3px 0", fontSize: 12 },
planStrip: {
padding: "8px 12px",
background: "#0d0e17",
borderBottom: "1px solid #2a2b36",
},
planErrorStrip: {
padding: "8px 12px",
fontSize: 12,
color: "#fca5a5",
background: "#3d1111",
borderBottom: "1px solid #7f1d1d",
},
// Single-pane layout used by MarkdownCanvas (when not in split) and
// TextCanvas. Stretches the single child column across the full
// modal body width.
bodySingle: { flex: 1, display: "grid", gridTemplateColumns: "1fr", overflow: "hidden" },
// Source / Preview / Split segmented control.
segGroup: { display: "inline-flex", background: "#0d0e17", border: "1px solid #2c2d46", borderRadius: 6, overflow: "hidden" },
seg: {
padding: "5px 10px", fontSize: 11,
background: "transparent", color: "#9092b5",
border: "none", cursor: "pointer",
},
segActive: {
padding: "5px 10px", fontSize: 11, fontWeight: 600,
background: "#1e3a5f", color: "#fff",
border: "none", cursor: "pointer",
},
// Markdown source pane — read-only preformatted view, distinct from
// the editable <textarea> the CodeCanvas uses.
mdSource: {
flex: 1, margin: 0, padding: 12,
background: "#0d0e17", color: "#e6e8ff",
border: "1px solid #2c2d46", borderRadius: 6,
fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 13, lineHeight: 1.55,
whiteSpace: "pre-wrap", wordBreak: "break-word",
overflow: "auto",
},
// Rendered preview pane. Subset of CSS that matches the look of
// GitHub's rendered README without pulling in a markdown library.
mdRendered: {
flex: 1, padding: "8px 16px 24px",
background: "#0d0e17", color: "#e6e8ff",
border: "1px solid #2c2d46", borderRadius: 6,
overflow: "auto",
fontSize: 14, lineHeight: 1.6,
},
};