| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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", |
| }; |
|
|
| |
| |
| |
| const RUNNABLE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]); |
| const MARKDOWN_EXTS = new Set(["md", "markdown", "mdx"]); |
|
|
| |
| const EXT_TO_LANG = { |
| py: "python", js: "javascript", mjs: "javascript", cjs: "javascript", |
| sh: "bash", bash: "bash", |
| }; |
|
|
| |
| |
| 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(); |
| } |
|
|
| |
| |
| 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) { |
| |
| 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" }; |
| } |
| |
| |
| 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" }; |
| } |
|
|
| |
| |
| |
| |
| function escapeHtml(s) { |
| return s |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """); |
| } |
| function renderMarkdown(src) { |
| if (!src) return ""; |
| let s = escapeHtml(src); |
| |
| s = s.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_m, _lang, code) => |
| `<pre class="md-code"><code>${code}</code></pre>`, |
| ); |
| |
| 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>"); |
| |
| 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>"); |
| |
| s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\/|\.)[^)\s]+)\)/g, |
| '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); |
| |
| 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>`); |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| |
| 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} />; |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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); |
| |
| |
| |
| 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"); |
| 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); |
| |
| |
| 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> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| function MarkdownCanvas({ initialCode, filename, onClose, mode }) { |
| const [view, setView] = useState("split"); |
| const html = useMemo(() => renderMarkdown(initialCode || ""), [initialCode]); |
| |
| |
| 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, |
| }, |
| }; |
|
|