feat(ui): enhance RepoConfig with new sections and improved state management
Deploy / Build (pull_request) Successful in 32s
Deploy / Build and Push Docker Image (pull_request) Has been skipped
Deploy / Build (push) Successful in 34s
Deploy / Build and Push Docker Image (push) Successful in 1m8s

This commit is contained in:
2026-07-26 18:32:28 +02:00
parent 5d8791a117
commit 21a80f025f
2 changed files with 479 additions and 220 deletions
+477 -220
View File
@@ -1,32 +1,65 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { api } from "../services/api";
import { toast } from "react-toastify";
import { ArrowLeft, X, Plus } from "lucide-react";
import {
ArrowLeft,
ChevronRight,
Container,
KeyRound,
MessageSquare,
Package,
Plus,
Save,
Server,
Shield,
Terminal,
X,
} from "lucide-react";
import { DEFAULT_INSTANCE_TYPE, INSTANCE_TYPES, isPresetInstanceType, normalizeInstanceType } from "../lib/instanceTypes";
import { usePageTitle } from "../hooks/usePageTitle";
function defaultConfig() {
type RepoConfigState = {
instanceType: string;
inactivityHours: number;
port: number;
denyList: string[];
disabledCommands: string[];
envVars: Record<string, string>;
preinstallTools: string[];
nodeVersion: string;
useDockerCompose: boolean;
composeFilePath: string;
aptPackages: string[];
setupCommands: string[];
buildCommands: string[];
postBuildCommands: string[];
runCommand: string;
};
type SectionKey = "compute" | "runtime" | "docker" | "commands" | "environment" | "access" | "commentCommands";
function defaultConfig(): RepoConfigState {
return {
instanceType: DEFAULT_INSTANCE_TYPE,
inactivityHours: 12,
port: 3000,
denyList: [] as string[],
disabledCommands: [] as string[],
envVars: {} as Record<string, string>,
preinstallTools: [] as string[],
denyList: [],
disabledCommands: [],
envVars: {},
preinstallTools: [],
nodeVersion: "lts/*",
useDockerCompose: false,
composeFilePath: "docker-compose.yml",
aptPackages: [] as string[],
setupCommands: [] as string[],
buildCommands: [] as string[],
postBuildCommands: [] as string[],
aptPackages: [],
setupCommands: [],
buildCommands: [],
postBuildCommands: [],
runCommand: "",
};
}
function normalizeConfig(raw: any) {
function normalizeConfig(raw: any): RepoConfigState {
const base = defaultConfig();
if (!raw) return base;
return {
@@ -55,8 +88,9 @@ function parseAptPackages(value: string) {
export function RepoConfig() {
const { owner, repo } = useParams<{ owner: string; repo: string }>();
const navigate = useNavigate();
const [config, setConfig] = useState<any>(defaultConfig());
const [config, setConfig] = useState<RepoConfigState>(defaultConfig());
const [aptPackagesInput, setAptPackagesInput] = useState("");
const [activeSection, setActiveSection] = useState<SectionKey>("compute");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
usePageTitle(owner && repo ? `Configure ${owner}/${repo}` : "Configure Repo");
@@ -73,7 +107,6 @@ export function RepoConfig() {
setConfig(nextConfig);
setAptPackagesInput(nextConfig.aptPackages.join(" "));
} else if (res.status === 404) {
// No config saved yet — start from defaults.
setConfig(defaultConfig());
setAptPackagesInput("");
} else {
@@ -84,22 +117,22 @@ export function RepoConfig() {
return () => { active = false; };
}, [owner, repo]);
const update = (field: string, value: any) => {
setConfig((prev: any) => ({ ...prev, [field]: value }));
const update = <K extends keyof RepoConfigState>(field: K, value: RepoConfigState[K]) => {
setConfig(prev => ({ ...prev, [field]: value }));
};
const togglePreinstall = (tool: string, checked: boolean) => {
setConfig((prev: any) => {
setConfig(prev => {
const current = Array.isArray(prev.preinstallTools) ? prev.preinstallTools : [];
const next = checked
? [...new Set([...current, tool])]
: current.filter((item: string) => item !== tool);
: current.filter(item => item !== tool);
return { ...prev, preinstallTools: next };
});
};
const setDockerCompose = (checked: boolean) => {
setConfig((prev: any) => {
setConfig(prev => {
const current = Array.isArray(prev.preinstallTools) ? prev.preinstallTools : [];
return {
...prev,
@@ -112,209 +145,414 @@ export function RepoConfig() {
const handleSave = async () => {
if (!owner || !repo) return;
setSaving(true);
const nextAptPackages = parseAptPackages(aptPackagesInput);
const res = await api.repos.saveConfig({
owner,
repo,
...config,
aptPackages: parseAptPackages(aptPackagesInput),
aptPackages: nextAptPackages,
});
setSaving(false);
if (res.ok) {
toast.success("Config saved");
setConfig(prev => ({ ...prev, aptPackages: nextAptPackages }));
toast.success("Repo settings saved");
} else {
toast.error(res.message || "Failed to save config");
toast.error(res.message || "Failed to save settings");
}
};
const sections = useMemo(() => buildSections(config), [config]);
const currentSection = sections.find(section => section.key === activeSection) ?? sections[0];
if (loading) {
return (
<div className="max-w-3xl space-y-3">
<div className="max-w-6xl space-y-4">
<div className="h-8 w-64 bg-gray-100 dark:bg-slate-800 rounded animate-pulse" />
{[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.85fr)]">
<div className="space-y-2">
{[...Array(7)].map((_, i) => <div key={i} className="h-20 bg-gray-100 dark:bg-slate-800 rounded-lg animate-pulse" />)}
</div>
<div className="h-96 bg-gray-100 dark:bg-slate-800 rounded-lg animate-pulse" />
</div>
</div>
);
}
return (
<div className="max-w-3xl space-y-4">
<div className="max-w-6xl space-y-5">
<div>
<Link to="/repos" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"><ArrowLeft size={14} /> Back to Repos</Link>
<Link to="/repos" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<ArrowLeft size={14} /> Repositories
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Configure Repo</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono">{owner}/{repo}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold">Repo Settings</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono truncate">{owner}/{repo}</p>
</div>
</div>
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>Instance Type</label>
<select
value={isPresetInstanceType(config.instanceType) ? config.instanceType : "custom"}
onChange={e => update("instanceType", e.target.value === "custom" ? "" : e.target.value)}
className={inputCls}
>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{!isPresetInstanceType(config.instanceType) && (
<input type="text" value={config.instanceType} placeholder="Custom instance type" className={`${inputCls} mt-1`}
onChange={e => update("instanceType", normalizeInstanceType(e.target.value, ""))} />
)}
</div>
<div>
<label className={labelCls}>App Port (default: 3000)</label>
<input type="number" value={config.port} onChange={e => update("port", Number(e.target.value))} className={inputCls} min={1} max={65535} />
</div>
</div>
<div>
<label className={labelCls}>Inactivity Kill Timer: {config.inactivityHours}h</label>
<input type="range" min={0.5} max={72} step={0.5} value={config.inactivityHours}
onChange={e => update("inactivityHours", Number(e.target.value))}
className="w-full mt-1" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0.5h</span><span>12h</span><span>72h</span>
</div>
</div>
<div>
<label className={labelCls}>Deny List (comma-separated usernames)</label>
<input type="text"
value={config.denyList.join(", ")}
onChange={e => update("denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))}
className={inputCls}
placeholder="dependabot, renovate-bot"
/>
</div>
<div>
<label className={labelCls}>Preinstall Options</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{PREINSTALL_OPTIONS.map(option => {
const checked = (config.preinstallTools || []).includes(option.value);
const locked = option.value === "docker" && config.useDockerCompose;
return (
<label key={option.value} className="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-slate-700 px-3 py-2 text-sm">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={e => togglePreinstall(option.value, e.target.checked)}
className="rounded"
/>
<span>{option.label}</span>
</label>
);
})}
</div>
{(config.preinstallTools || []).includes("node") && (
<div className="mt-3">
<label className={labelCls}>Node Version</label>
<input type="text"
value={config.nodeVersion || "lts/*"}
onChange={e => update("nodeVersion", e.target.value)}
className={inputCls}
placeholder="lts/*, 22, 20.11.1"
/>
</div>
)}
</div>
<div>
<label className={labelCls}>Additional Apt Packages (space-separated)</label>
<input type="text"
value={aptPackagesInput}
onChange={e => {
setAptPackagesInput(e.target.value);
update("aptPackages", parseAptPackages(e.target.value));
}}
className={inputCls}
placeholder="ffmpeg libpq-dev"
/>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<input type="checkbox" id="compose" checked={config.useDockerCompose}
onChange={e => setDockerCompose(e.target.checked)} className="rounded" />
<label htmlFor="compose" className="text-sm font-medium">Use Docker Compose</label>
</div>
{config.useDockerCompose ? (
<div>
<label className={labelCls}>Compose File Path</label>
<input type="text" value={config.composeFilePath || "docker-compose.yml"}
onChange={e => update("composeFilePath", e.target.value)}
className={inputCls} placeholder="docker-compose.yml" />
</div>
) : (
<div className="space-y-3">
<CommandList label="Build Commands" value={config.buildCommands}
onChange={v => update("buildCommands", v)} />
<CommandList label="Post-Build Commands (optional)" value={config.postBuildCommands}
onChange={v => update("postBuildCommands", v)} />
<div>
<label className={labelCls}>Run Command</label>
<input type="text" value={config.runCommand || ""}
onChange={e => update("runCommand", e.target.value)}
className={inputCls} placeholder="python app.py, ./server, pnpm start" />
</div>
</div>
)}
</div>
<CommandList label="Setup Commands (run once on first provision)"
value={config.setupCommands}
onChange={v => update("setupCommands", v)} />
<EnvVarsEditor value={config.envVars || {}}
onChange={v => update("envVars", v)} />
<CommandToggles value={config.disabledCommands || []}
onChange={v => update("disabledCommands", v)} />
<div className="flex justify-end gap-2">
<div className="flex gap-2">
<button
onClick={() => navigate("/repos")}
className="px-5 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
className="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save Config"}
<Save size={16} /> {saving ? "Saving..." : "Save"}
</button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,0.85fr)]">
<section className="space-y-2">
{sections.map(section => {
const selected = section.key === activeSection;
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
className={`w-full rounded-lg border p-4 text-left transition-colors ${
selected
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:border-slate-600 dark:hover:bg-slate-800/80"
}`}
>
<div className="flex items-start gap-3">
<span className={`mt-0.5 rounded-md border p-2 ${
selected
? "border-blue-200 bg-white text-blue-600 dark:border-blue-800 dark:bg-slate-900 dark:text-blue-300"
: "border-gray-200 bg-gray-50 text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
}`}>
{section.icon}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-3">
<span className="font-medium text-sm">{section.title}</span>
<ChevronRight size={16} className={selected ? "text-blue-500" : "text-gray-400"} />
</span>
<span className="mt-1 block text-sm text-gray-500 dark:text-slate-400">{section.summary}</span>
</span>
</div>
</button>
);
})}
</section>
<section className="rounded-lg border border-gray-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-800">
<div className="mb-5 flex items-start gap-3">
<span className="rounded-md border border-gray-200 bg-gray-50 p-2 text-gray-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
{currentSection.icon}
</span>
<div>
<h2 className="font-semibold">{currentSection.title}</h2>
<p className="text-sm text-gray-500 dark:text-slate-400">{currentSection.description}</p>
</div>
</div>
{activeSection === "compute" && (
<div className="space-y-4">
<Field label="Instance type">
<select
value={isPresetInstanceType(config.instanceType) ? config.instanceType : "custom"}
onChange={e => update("instanceType", e.target.value === "custom" ? "" : e.target.value)}
className={inputCls}
>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} - {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{!isPresetInstanceType(config.instanceType) && (
<input
type="text"
value={config.instanceType}
placeholder="Custom instance type"
className={`${inputCls} mt-2`}
onChange={e => update("instanceType", normalizeInstanceType(e.target.value, ""))}
/>
)}
</Field>
<Field label={`Inactivity timer: ${config.inactivityHours}h`}>
<input
type="range"
min={0.5}
max={72}
step={0.5}
value={config.inactivityHours}
onChange={e => update("inactivityHours", Number(e.target.value))}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-400">
<span>0.5h</span><span>12h</span><span>72h</span>
</div>
</Field>
<Field label="Preview port">
<input
type="number"
value={config.port}
onChange={e => update("port", Number(e.target.value))}
className={inputCls}
min={1}
max={65535}
/>
</Field>
</div>
)}
{activeSection === "runtime" && (
<div className="space-y-4">
<Field label="Preinstall tools">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PREINSTALL_OPTIONS.map(option => {
const checked = config.preinstallTools.includes(option.value);
const locked = option.value === "docker" && config.useDockerCompose;
return (
<label key={option.value} className="flex min-h-11 items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-slate-700">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={e => togglePreinstall(option.value, e.target.checked)}
className="rounded"
/>
<span>{option.label}</span>
</label>
);
})}
</div>
</Field>
{config.preinstallTools.includes("node") && (
<Field label="Node version">
<input
type="text"
value={config.nodeVersion || "lts/*"}
onChange={e => update("nodeVersion", e.target.value)}
className={inputCls}
placeholder="lts/*, 22, 20.11.1"
/>
</Field>
)}
<Field label="Additional apt packages">
<input
type="text"
value={aptPackagesInput}
onChange={e => {
setAptPackagesInput(e.target.value);
update("aptPackages", parseAptPackages(e.target.value));
}}
className={inputCls}
placeholder="ffmpeg libpq-dev"
/>
</Field>
</div>
)}
{activeSection === "docker" && (
<div className="space-y-4">
<label className="flex items-center justify-between gap-4 rounded-lg border border-gray-200 p-3 dark:border-slate-700">
<span>
<span className="block text-sm font-medium">Use Docker Compose</span>
<span className="block text-sm text-gray-500 dark:text-slate-400">Run the preview with compose instead of build and run commands.</span>
</span>
<input
type="checkbox"
checked={config.useDockerCompose}
onChange={e => setDockerCompose(e.target.checked)}
className="h-5 w-5 rounded"
/>
</label>
{config.useDockerCompose && (
<Field label="Compose file path">
<input
type="text"
value={config.composeFilePath || "docker-compose.yml"}
onChange={e => update("composeFilePath", e.target.value)}
className={inputCls}
placeholder="docker-compose.yml"
/>
</Field>
)}
</div>
)}
{activeSection === "commands" && (
<div className="space-y-4">
<CommandList label="Setup commands" value={config.setupCommands} onChange={v => update("setupCommands", v)} placeholder="apt-get update" />
{!config.useDockerCompose && (
<>
<CommandList label="Build commands" value={config.buildCommands} onChange={v => update("buildCommands", v)} placeholder="pnpm install && pnpm build" />
<CommandList label="Post-build commands" value={config.postBuildCommands} onChange={v => update("postBuildCommands", v)} placeholder="prisma migrate deploy" />
<Field label="Run command">
<input
type="text"
value={config.runCommand || ""}
onChange={e => update("runCommand", e.target.value)}
className={inputCls}
placeholder="python app.py, ./server, pnpm start"
/>
</Field>
</>
)}
{config.useDockerCompose && (
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-300">
Docker Compose mode uses the compose file path from the Docker section.
</p>
)}
</div>
)}
{activeSection === "environment" && (
<EnvVarsEditor value={config.envVars || {}} onChange={v => update("envVars", v)} />
)}
{activeSection === "access" && (
<Field label="Deny list">
<input
type="text"
value={config.denyList.join(", ")}
onChange={e => update("denyList", e.target.value.split(",").map(s => s.trim()).filter(Boolean))}
className={inputCls}
placeholder="dependabot, renovate-bot"
/>
</Field>
)}
{activeSection === "commentCommands" && (
<CommandToggles value={config.disabledCommands || []} onChange={v => update("disabledCommands", v)} />
)}
</section>
</div>
</div>
);
}
function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) {
function buildSections(config: RepoConfigState) {
return [
{
key: "compute" as const,
title: "Compute",
description: "Instance size, exposed port, and automatic idle shutdown.",
summary: `${config.instanceType || "Custom instance"} on port ${config.port}; stops after ${config.inactivityHours}h idle`,
icon: <Server size={18} />,
},
{
key: "runtime" as const,
title: "Runtime Packages",
description: "Tools installed on the preview instance before app setup.",
summary: summarizeList([...config.preinstallTools, ...config.aptPackages], "No runtime packages selected"),
icon: <Package size={18} />,
},
{
key: "docker" as const,
title: "Docker Compose",
description: "Choose whether the preview starts from a compose file.",
summary: config.useDockerCompose ? `Enabled: ${config.composeFilePath || "docker-compose.yml"}` : "Disabled",
icon: <Container size={18} />,
},
{
key: "commands" as const,
title: "Deploy Commands",
description: "Commands used to prepare, build, and start the preview.",
summary: config.useDockerCompose
? "Compose file controls startup"
: `${config.buildCommands.length} build, ${config.postBuildCommands.length} post-build, ${config.runCommand ? "run set" : "no run command"}`,
icon: <Terminal size={18} />,
},
{
key: "environment" as const,
title: "Environment Variables",
description: "Variables passed to the preview app.",
summary: `${Object.keys(config.envVars || {}).filter(Boolean).length} variables`,
icon: <KeyRound size={18} />,
},
{
key: "access" as const,
title: "Access Controls",
description: "Users that are blocked from creating previews for this repo.",
summary: summarizeList(config.denyList, "No users blocked"),
icon: <Shield size={18} />,
},
{
key: "commentCommands" as const,
title: "PR Comment Commands",
description: "Reviewer commands available from pull request comments.",
summary: `${CONFIGURABLE_COMMANDS.length - config.disabledCommands.length} of ${CONFIGURABLE_COMMANDS.length} commands enabled`,
icon: <MessageSquare size={18} />,
},
];
}
function summarizeList(values: string[], empty: string) {
const clean = values.filter(Boolean);
if (clean.length === 0) return empty;
if (clean.length <= 3) return clean.join(", ");
return `${clean.slice(0, 3).join(", ")} +${clean.length - 3} more`;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className={labelCls}>{label}</label>
<div className="space-y-1">
{children}
</div>
);
}
function CommandList({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string[];
onChange: (v: string[]) => void;
placeholder: string;
}) {
return (
<Field label={label}>
<div className="space-y-2">
{value.length === 0 && (
<div className="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-sm text-gray-500 dark:border-slate-600 dark:text-slate-400">
No commands configured.
</div>
)}
{value.map((cmd, i) => (
<div key={i} className="flex gap-2">
<input type="text" value={cmd}
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }}
className={inputCls} placeholder="make build" />
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"><X size={16} /></button>
<input
type="text"
value={cmd}
onChange={e => {
const next = [...value];
next[i] = e.target.value;
onChange(next);
}}
className={inputCls}
placeholder={placeholder}
/>
<button
type="button"
onClick={() => onChange(value.filter((_, j) => j !== i))}
className="rounded-lg px-2 text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
title="Remove command"
>
<X size={16} />
</button>
</div>
))}
<button onClick={() => onChange([...value, ""])} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<button
type="button"
onClick={() => onChange([...value, ""])}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline dark:text-blue-400"
>
<Plus size={14} /> Add command
</button>
</div>
</div>
</Field>
);
}
@@ -327,14 +565,12 @@ const PREINSTALL_OPTIONS: { value: string; label: string }[] = [
{ value: "build-essential", label: "Build tools" },
];
// Configurable `/pp` commands. `help` is intentionally omitted — it can never be
// disabled so reviewers always have a way to discover command state.
const CONFIGURABLE_COMMANDS: { name: string; description: string }[] = [
{ name: "rebuild", description: "Rebuild the preview (reuse instance, or re-provision if stopped)." },
{ name: "rebuild", description: "Rebuild the preview." },
{ name: "stop", description: "Stop and terminate the preview instance." },
{ name: "start", description: "Start a stopped/ignored preview, or create one." },
{ name: "logs", description: "Post the last 50 lines of logs as a comment." },
{ name: "ignore", description: "Ignore this PR — skip future pushes and commands." },
{ name: "start", description: "Start a stopped or ignored preview." },
{ name: "logs", description: "Post the last 50 log lines." },
{ name: "ignore", description: "Ignore future pushes and commands for this PR." },
];
function CommandToggles({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
@@ -343,27 +579,27 @@ function CommandToggles({ value, onChange }: { value: string[]; onChange: (v: st
else if (!value.includes(name)) onChange([...value, name]);
};
return (
<div>
<label className={labelCls}>PR Comment Commands</label>
<p className="text-xs text-gray-400 dark:text-slate-500 mb-2">
Control which <code>/pp</code> commands reviewers can run from PR comments. <code>/pp help</code> is always available.
<div className="space-y-2">
{CONFIGURABLE_COMMANDS.map(cmd => {
const enabled = !value.includes(cmd.name);
return (
<label key={cmd.name} className="flex items-start justify-between gap-4 rounded-lg border border-gray-200 p-3 dark:border-slate-700">
<span className="min-w-0">
<code className="font-mono text-sm">/pp {cmd.name}</code>
<span className="mt-1 block text-sm text-gray-500 dark:text-slate-400">{cmd.description}</span>
</span>
<input
type="checkbox"
checked={enabled}
onChange={e => toggle(cmd.name, e.target.checked)}
className="mt-1 h-5 w-5 rounded"
/>
</label>
);
})}
<p className="text-xs text-gray-500 dark:text-slate-400">
<code>/pp help</code> is always enabled.
</p>
<div className="space-y-1">
{CONFIGURABLE_COMMANDS.map(cmd => {
const enabled = !value.includes(cmd.name);
return (
<label key={cmd.name} className="flex items-start gap-2 py-1 cursor-pointer">
<input type="checkbox" checked={enabled}
onChange={e => toggle(cmd.name, e.target.checked)}
className="rounded mt-0.5" />
<span className="text-sm">
<code className="font-mono">/pp {cmd.name}</code>
<span className="text-gray-500 dark:text-slate-400"> {cmd.description}</span>
</span>
</label>
);
})}
</div>
</div>
);
}
@@ -379,34 +615,55 @@ function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onC
}
onChange(next);
};
const removeEntry = (k: string) => {
const removeEntry = (key: string) => {
const next = { ...value };
delete next[k];
delete next[key];
onChange(next);
};
return (
<div>
<label className={labelCls}>Environment Variables</label>
<div className="space-y-1">
{entries.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<input type="text" value={k} placeholder="KEY"
onChange={e => updateEntry(k, e.target.value, v)}
className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} />
<input type="password" value={v} placeholder="value"
onChange={e => updateEntry(k, k, e.target.value)}
className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} />
<button onClick={() => removeEntry(k)} className="text-red-500 hover:text-red-700 px-2"><X size={16} /></button>
</div>
))}
<button onClick={addEntry} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<Plus size={14} /> Add variable
</button>
</div>
<div className="space-y-2">
{entries.length === 0 && (
<div className="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-sm text-gray-500 dark:border-slate-600 dark:text-slate-400">
No environment variables configured.
</div>
)}
{entries.map(([key, val], i) => (
<div key={i} className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<input
type="text"
value={key}
placeholder="KEY"
onChange={e => updateEntry(key, e.target.value, val)}
className={`${inputCls} font-mono text-xs`}
/>
<input
type="password"
value={val}
placeholder="value"
onChange={e => updateEntry(key, key, e.target.value)}
className={`${inputCls} font-mono text-xs`}
/>
<button
type="button"
onClick={() => removeEntry(key)}
className="rounded-lg px-2 text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
title="Remove variable"
>
<X size={16} />
</button>
</div>
))}
<button
type="button"
onClick={addEntry}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline dark:text-blue-400"
>
<Plus size={14} /> Add variable
</button>
</div>
);
}
const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1";
const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1.5";
const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500";