0a0af4e02a
The ManifestEditor had a feedback loop: 1. State change → buildManifest changes → onChange notifies parent 2. Parent updates manifestData → new manifest prop 3. Loading effect sets all state from manifest (arrays get new refs even if same content) 4. New array refs → buildManifest changes → onChange fires again → loop Fix: track the last-sent manifest via a ref and only call onChange when the serialized built manifest actually differs. This breaks the cycle because after the loading effect syncs state, the rebuilt manifest is identical in content so we skip the parent notification.
859 lines
24 KiB
TypeScript
859 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Icon } from "./icon";
|
|
import { extractErrorMessage } from "../utils/errors";
|
|
import {
|
|
compileToolDefinition,
|
|
type ToolDefinitionManifest,
|
|
} from "../api/tool_definitions";
|
|
|
|
interface PackageEntry {
|
|
name: string;
|
|
}
|
|
|
|
interface MountEntry {
|
|
name: string;
|
|
target: string;
|
|
source_type: string;
|
|
writable: boolean;
|
|
owner: string;
|
|
mode: string;
|
|
file_mode: string;
|
|
readonly: boolean;
|
|
git_mount_ref: string;
|
|
}
|
|
|
|
interface ManifestEditorProps {
|
|
manifest: Record<string, unknown> | null;
|
|
baseDefinitions: ToolDefinitionManifest[];
|
|
onChange: (manifest: Record<string, unknown>) => void;
|
|
definitionId?: string | null;
|
|
}
|
|
|
|
export const ManifestEditor = ({
|
|
manifest,
|
|
baseDefinitions,
|
|
onChange,
|
|
definitionId,
|
|
}: ManifestEditorProps) => {
|
|
const [baseImage, setBaseImage] = useState("");
|
|
const [baseDefinitionId, setBaseDefinitionId] = useState("");
|
|
const [aptPackages, setAptPackages] = useState<PackageEntry[]>([]);
|
|
const [npmPackages, setNpmPackages] = useState<PackageEntry[]>([]);
|
|
const [pipPackages, setPipPackages] = useState<PackageEntry[]>([]);
|
|
const [nodeVersion, setNodeVersion] = useState("");
|
|
const [userName, setUserName] = useState("user");
|
|
const [userUid, setUserUid] = useState("1000");
|
|
const [userGid, setUserGid] = useState("1000");
|
|
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
|
|
const [buildScripts, setBuildScripts] = useState<string[]>([""]);
|
|
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
|
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
|
const [command, setCommand] = useState<string[]>([""]);
|
|
const [workingDir, setWorkingDir] = useState("/workspace");
|
|
const [stdinOpen, setStdinOpen] = useState(true);
|
|
const [tty, setTty] = useState(true);
|
|
|
|
const [preview, setPreview] = useState<{
|
|
dockerfile: string;
|
|
compose: string;
|
|
entrypoint: string;
|
|
} | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
|
|
// Load manifest into form
|
|
useEffect(() => {
|
|
if (!manifest) return;
|
|
|
|
const pkgs = (manifest.packages as Record<string, unknown>) || {};
|
|
setBaseImage((manifest.base_image as string) || "");
|
|
setBaseDefinitionId((manifest.base_definition_id as string) || "");
|
|
setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p })));
|
|
setNpmPackages(
|
|
((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })),
|
|
);
|
|
setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p })));
|
|
setNodeVersion((pkgs.node as Record<string, string>)?.version || "");
|
|
|
|
const user = (manifest.user as Record<string, unknown>) || {};
|
|
setUserName((user.name as string) || "user");
|
|
setUserUid(String(user.uid || "1000"));
|
|
setUserGid(String(user.gid || "1000"));
|
|
|
|
const env = (manifest.env as Record<string, string>) || {};
|
|
setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
|
|
const scripts = (manifest.scripts as Record<string, string[]>) || {};
|
|
setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]);
|
|
setStartupScripts(
|
|
(scripts.startup || []).length > 0 ? scripts.startup : [""],
|
|
);
|
|
|
|
const mts = (manifest.mounts as MountEntry[]) || [];
|
|
setMounts(mts);
|
|
|
|
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
|
setCommand((runtime.command as string[]) || [""]);
|
|
setWorkingDir((runtime.working_dir as string) || "/workspace");
|
|
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
|
setTty((runtime.tty as boolean) ?? true);
|
|
}, [manifest]);
|
|
|
|
// Build manifest from form state
|
|
const buildManifest = useCallback((): Record<string, unknown> => {
|
|
const packages: Record<string, unknown> = {};
|
|
const apt = aptPackages.map((p) => p.name).filter(Boolean);
|
|
if (apt.length) packages.apt = apt;
|
|
const npm = npmPackages.map((p) => p.name).filter(Boolean);
|
|
if (npm.length) packages.npm_global = npm;
|
|
const pip = pipPackages.map((p) => p.name).filter(Boolean);
|
|
if (pip.length) packages.pip = pip;
|
|
if (nodeVersion) packages.node = { version: nodeVersion };
|
|
|
|
const env: Record<string, string> = {};
|
|
envVars.forEach(({ key, value }) => {
|
|
if (key) env[key] = value;
|
|
});
|
|
|
|
const scripts: Record<string, string[]> = {};
|
|
const build = buildScripts.filter(Boolean);
|
|
if (build.length) scripts.build = build;
|
|
const startup = startupScripts.filter(Boolean);
|
|
if (startup.length) scripts.startup = startup;
|
|
|
|
const mts = mounts.filter((m) => m.name && m.target);
|
|
|
|
const result: Record<string, unknown> = {
|
|
packages,
|
|
user: {
|
|
name: userName,
|
|
uid: parseInt(userUid) || 1000,
|
|
gid: parseInt(userGid) || 1000,
|
|
create_home: true,
|
|
shell: "/bin/bash",
|
|
},
|
|
env,
|
|
scripts,
|
|
mounts: mts,
|
|
runtime: {
|
|
command:
|
|
command.filter(Boolean).length > 0
|
|
? command.filter(Boolean)
|
|
: ["/bin/bash"],
|
|
stdin_open: stdinOpen,
|
|
tty: tty,
|
|
working_dir: workingDir,
|
|
},
|
|
};
|
|
|
|
if (baseImage) result.base_image = baseImage;
|
|
if (baseDefinitionId) result.base_definition_id = baseDefinitionId;
|
|
|
|
return result;
|
|
}, [
|
|
aptPackages,
|
|
npmPackages,
|
|
pipPackages,
|
|
nodeVersion,
|
|
userName,
|
|
userUid,
|
|
userGid,
|
|
envVars,
|
|
buildScripts,
|
|
startupScripts,
|
|
mounts,
|
|
command,
|
|
workingDir,
|
|
stdinOpen,
|
|
tty,
|
|
baseImage,
|
|
baseDefinitionId,
|
|
]);
|
|
|
|
// Notify parent of changes — only when built manifest actually differs
|
|
// from what we last sent, to avoid feedback loops with the manifest prop.
|
|
const lastSentRef = useRef<string>("");
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
|
|
useEffect(() => {
|
|
const m = buildManifest();
|
|
const serialized = JSON.stringify(m);
|
|
if (serialized !== lastSentRef.current) {
|
|
lastSentRef.current = serialized;
|
|
onChangeRef.current(m);
|
|
}
|
|
}, [buildManifest]);
|
|
|
|
const handlePreview = async () => {
|
|
if (!definitionId) {
|
|
setPreviewError("Save the tool definition first to preview");
|
|
return;
|
|
}
|
|
setPreviewLoading(true);
|
|
setPreviewError(null);
|
|
try {
|
|
const result = await compileToolDefinition(definitionId);
|
|
setPreview({
|
|
dockerfile: result.dockerfile,
|
|
compose: result.compose,
|
|
entrypoint: result.entrypoint,
|
|
});
|
|
} catch (err) {
|
|
setPreviewError(extractErrorMessage(err));
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]);
|
|
const removeAptPackage = (idx: number) =>
|
|
setAptPackages(aptPackages.filter((_, i) => i !== idx));
|
|
const updateAptPackage = (idx: number, name: string) => {
|
|
const copy = [...aptPackages];
|
|
copy[idx] = { name };
|
|
setAptPackages(copy);
|
|
};
|
|
|
|
const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]);
|
|
const removeNpmPackage = (idx: number) =>
|
|
setNpmPackages(npmPackages.filter((_, i) => i !== idx));
|
|
const updateNpmPackage = (idx: number, name: string) => {
|
|
const copy = [...npmPackages];
|
|
copy[idx] = { name };
|
|
setNpmPackages(copy);
|
|
};
|
|
|
|
const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]);
|
|
const removePipPackage = (idx: number) =>
|
|
setPipPackages(pipPackages.filter((_, i) => i !== idx));
|
|
const updatePipPackage = (idx: number, name: string) => {
|
|
const copy = [...pipPackages];
|
|
copy[idx] = { name };
|
|
setPipPackages(copy);
|
|
};
|
|
|
|
const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]);
|
|
const removeEnvVar = (idx: number) =>
|
|
setEnvVars(envVars.filter((_, i) => i !== idx));
|
|
const updateEnvVar = (idx: number, field: "key" | "value", val: string) => {
|
|
const copy = [...envVars];
|
|
copy[idx] = { ...copy[idx], [field]: val };
|
|
setEnvVars(copy);
|
|
};
|
|
|
|
const addBuildScript = () => setBuildScripts([...buildScripts, ""]);
|
|
const removeBuildScript = (idx: number) =>
|
|
setBuildScripts(buildScripts.filter((_, i) => i !== idx));
|
|
const updateBuildScript = (idx: number, val: string) => {
|
|
const copy = [...buildScripts];
|
|
copy[idx] = val;
|
|
setBuildScripts(copy);
|
|
};
|
|
|
|
const addStartupScript = () => setStartupScripts([...startupScripts, ""]);
|
|
const removeStartupScript = (idx: number) =>
|
|
setStartupScripts(startupScripts.filter((_, i) => i !== idx));
|
|
const updateStartupScript = (idx: number, val: string) => {
|
|
const copy = [...startupScripts];
|
|
copy[idx] = val;
|
|
setStartupScripts(copy);
|
|
};
|
|
|
|
const addMount = () =>
|
|
setMounts([
|
|
...mounts,
|
|
{
|
|
name: "",
|
|
target: "",
|
|
source_type: "repo",
|
|
writable: true,
|
|
owner: "",
|
|
mode: "",
|
|
file_mode: "",
|
|
readonly: false,
|
|
git_mount_ref: "",
|
|
},
|
|
]);
|
|
const removeMount = (idx: number) =>
|
|
setMounts(mounts.filter((_, i) => i !== idx));
|
|
const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => {
|
|
const copy = [...mounts];
|
|
copy[idx] = { ...copy[idx], [field]: val };
|
|
setMounts(copy);
|
|
};
|
|
|
|
return (
|
|
<div className="stack" style={{ gap: "1.5rem" }}>
|
|
{/* Base Image */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Base Image</h4>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Base Definition</label>
|
|
<select
|
|
value={baseDefinitionId}
|
|
onChange={(e) => {
|
|
setBaseDefinitionId(e.target.value);
|
|
setBaseImage("");
|
|
}}
|
|
className="form-input"
|
|
>
|
|
<option value="">Custom image...</option>
|
|
{baseDefinitions.map((b) => (
|
|
<option key={b.id} value={b.id}>
|
|
{b.display_name} ({b.version})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Custom Base Image</label>
|
|
<input
|
|
type="text"
|
|
value={baseImage}
|
|
onChange={(e) => {
|
|
setBaseImage(e.target.value);
|
|
setBaseDefinitionId("");
|
|
}}
|
|
placeholder="e.g., ubuntu:24.04"
|
|
className="form-input"
|
|
disabled={!!baseDefinitionId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Packages */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Packages</h4>
|
|
|
|
<div className="form-group">
|
|
<label>Node.js Version</label>
|
|
<input
|
|
type="text"
|
|
value={nodeVersion}
|
|
onChange={(e) => setNodeVersion(e.target.value)}
|
|
placeholder="e.g., 20"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label>APT Packages</label>
|
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
|
{aptPackages.map((pkg, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={pkg.name}
|
|
onChange={(e) => updateAptPackage(idx, e.target.value)}
|
|
placeholder="e.g., neovim"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAptPackage(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addAptPackage}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add APT Package
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label>NPM Global Packages</label>
|
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
|
{npmPackages.map((pkg, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={pkg.name}
|
|
onChange={(e) => updateNpmPackage(idx, e.target.value)}
|
|
placeholder="e.g., @scope/pkg"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeNpmPackage(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addNpmPackage}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add NPM Package
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label>Pip Packages</label>
|
|
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
|
{pipPackages.map((pkg, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={pkg.name}
|
|
onChange={(e) => updatePipPackage(idx, e.target.value)}
|
|
placeholder="e.g., requests"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removePipPackage(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addPipPackage}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Pip Package
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Runtime User */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Runtime User</h4>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>User Name</label>
|
|
<input
|
|
type="text"
|
|
value={userName}
|
|
onChange={(e) => setUserName(e.target.value)}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>UID</label>
|
|
<input
|
|
type="number"
|
|
value={userUid}
|
|
onChange={(e) => setUserUid(e.target.value)}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>GID</label>
|
|
<input
|
|
type="number"
|
|
value={userGid}
|
|
onChange={(e) => setUserGid(e.target.value)}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Environment */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Environment Variables</h4>
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{envVars.map((ev, idx) => (
|
|
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={ev.key}
|
|
onChange={(e) => updateEnvVar(idx, "key", e.target.value)}
|
|
placeholder="KEY"
|
|
className="form-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={ev.value}
|
|
onChange={(e) => updateEnvVar(idx, "value", e.target.value)}
|
|
placeholder="value"
|
|
className="form-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeEnvVar(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addEnvVar}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Env Var
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Build Scripts */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{buildScripts.map((script, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="row"
|
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
|
>
|
|
<textarea
|
|
value={script}
|
|
onChange={(e) => updateBuildScript(idx, e.target.value)}
|
|
placeholder="git config --global user.email 'dev@example.com'"
|
|
className="form-input"
|
|
rows={2}
|
|
style={{
|
|
fontFamily: "monospace",
|
|
fontSize: "0.8125rem",
|
|
flex: 1,
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeBuildScript(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addBuildScript}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Build Script
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Startup Scripts */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>
|
|
Startup Scripts (run when container starts)
|
|
</h4>
|
|
<div className="stack" style={{ gap: "0.5rem" }}>
|
|
{startupScripts.map((script, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="row"
|
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
|
>
|
|
<textarea
|
|
value={script}
|
|
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
|
placeholder="chown -R user:user /workspace"
|
|
className="form-input"
|
|
rows={2}
|
|
style={{
|
|
fontFamily: "monospace",
|
|
fontSize: "0.8125rem",
|
|
flex: 1,
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeStartupScript(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addStartupScript}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Startup Script
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mounts */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Mount Schema</h4>
|
|
<div className="stack" style={{ gap: "1rem" }}>
|
|
{mounts.map((mount, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="stack"
|
|
style={{
|
|
gap: "0.5rem",
|
|
padding: "0.75rem",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "0.375rem",
|
|
}}
|
|
>
|
|
<div className="row" style={{ gap: "0.5rem" }}>
|
|
<input
|
|
type="text"
|
|
value={mount.name}
|
|
onChange={(e) => updateMount(idx, "name", e.target.value)}
|
|
placeholder="Name (e.g., workspace)"
|
|
className="form-input"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={mount.target}
|
|
onChange={(e) => updateMount(idx, "target", e.target.value)}
|
|
placeholder="Target (e.g., /workspace)"
|
|
className="form-input"
|
|
/>
|
|
<select
|
|
value={mount.source_type}
|
|
onChange={(e) =>
|
|
updateMount(idx, "source_type", e.target.value)
|
|
}
|
|
className="form-input"
|
|
>
|
|
<option value="repo">Repository</option>
|
|
<option value="ssh_key">SSH Key</option>
|
|
<option value="instance">Instance</option>
|
|
<option value="git_mount">Git Mount</option>
|
|
<option value="host_path">Host Path</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeMount(idx)}
|
|
className="button-icon"
|
|
title="Remove"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
</button>
|
|
</div>
|
|
<div className="row" style={{ gap: "0.5rem" }}>
|
|
<label
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={mount.writable}
|
|
onChange={(e) =>
|
|
updateMount(idx, "writable", e.target.checked)
|
|
}
|
|
/>
|
|
Writable
|
|
</label>
|
|
<label
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
fontSize: "0.875rem",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={mount.readonly}
|
|
onChange={(e) =>
|
|
updateMount(idx, "readonly", e.target.checked)
|
|
}
|
|
/>
|
|
Read-only
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={mount.owner}
|
|
onChange={(e) => updateMount(idx, "owner", e.target.value)}
|
|
placeholder="Owner (e.g., user)"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={mount.mode}
|
|
onChange={(e) => updateMount(idx, "mode", e.target.value)}
|
|
placeholder="Mode (e.g., 0755)"
|
|
className="form-input"
|
|
style={{ width: "100px" }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={mount.file_mode}
|
|
onChange={(e) =>
|
|
updateMount(idx, "file_mode", e.target.value)
|
|
}
|
|
placeholder="File mode (e.g., 0644)"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
{mount.source_type === "git_mount" && (
|
|
<input
|
|
type="text"
|
|
value={mount.git_mount_ref}
|
|
onChange={(e) =>
|
|
updateMount(idx, "git_mount_ref", e.target.value)
|
|
}
|
|
placeholder="Git mount ref"
|
|
className="form-input"
|
|
style={{ width: "120px" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addMount}
|
|
className="button-secondary"
|
|
style={{ width: "fit-content" }}
|
|
>
|
|
<Icon name="add" size="sm" /> Add Mount
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Runtime */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<h4 style={{ margin: 0 }}>Runtime</h4>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Command</label>
|
|
<input
|
|
type="text"
|
|
value={command.join(" ")}
|
|
onChange={(e) => setCommand(e.target.value.split(" "))}
|
|
placeholder="/bin/bash"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
<div className="form-group" style={{ flex: 1 }}>
|
|
<label>Working Directory</label>
|
|
<input
|
|
type="text"
|
|
value={workingDir}
|
|
onChange={(e) => setWorkingDir(e.target.value)}
|
|
placeholder="/workspace"
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="row" style={{ gap: "1rem" }}>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={stdinOpen}
|
|
onChange={(e) => setStdinOpen(e.target.checked)}
|
|
/>
|
|
stdin_open
|
|
</label>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={tty}
|
|
onChange={(e) => setTty(e.target.checked)}
|
|
/>
|
|
tty
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview */}
|
|
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<h4 style={{ margin: 0 }}>Live Preview</h4>
|
|
<button
|
|
type="button"
|
|
onClick={handlePreview}
|
|
disabled={previewLoading}
|
|
className="button-secondary"
|
|
>
|
|
<Icon name="refresh" size="sm" />
|
|
{previewLoading ? "Compiling..." : "Preview"}
|
|
</button>
|
|
</div>
|
|
{previewError && <p className="text-error">{previewError}</p>}
|
|
{preview && (
|
|
<div className="stack" style={{ gap: "1rem" }}>
|
|
<div>
|
|
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
|
Dockerfile
|
|
</label>
|
|
<pre
|
|
style={{
|
|
background: "var(--code-bg, #1e1e1e)",
|
|
color: "var(--code-fg, #d4d4d4)",
|
|
padding: "1rem",
|
|
borderRadius: "0.375rem",
|
|
overflow: "auto",
|
|
fontSize: "0.8125rem",
|
|
maxHeight: "300px",
|
|
}}
|
|
>
|
|
{preview.dockerfile}
|
|
</pre>
|
|
</div>
|
|
<div>
|
|
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
|
Compose
|
|
</label>
|
|
<pre
|
|
style={{
|
|
background: "var(--code-bg, #1e1e1e)",
|
|
color: "var(--code-fg, #d4d4d4)",
|
|
padding: "1rem",
|
|
borderRadius: "0.375rem",
|
|
overflow: "auto",
|
|
fontSize: "0.8125rem",
|
|
maxHeight: "200px",
|
|
}}
|
|
>
|
|
{preview.compose}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|