Files
stackpilot/frontend/src/components/env/EnvEditor.tsx
T
menzeljandClaude Opus 5 2afec08c4f
CI / build-and-push (push) Successful in 1m45s
Fix "Add variable" doing nothing, and size the editors to the viewport (0.42.2)
The env table derived its rows from the serialized text on every render,
and serialize() drops rows with an empty key — so a freshly added blank
row was discarded before it could be typed into. The rows are now owned
by the component and re-parsed only when `value` changes from outside,
with stable per-row ids so deleting a row no longer shifts the reveal
state onto its neighbour.

AppShell's <main> is content-height, so the editor page's `h-full`
collapsed to auto: Monaco and the raw .env textarea fell back to their
intrinsic size, the textarea to a two-row default. The page is now sized
against the viewport minus the top bar and page padding, so both editors
fill the screen, and the textarea gets min-h-0 so flex-1 can grow it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pMmFFkdfxkoYjcEcpZTa5
2026-08-31 00:49:40 +02:00

190 lines
7.2 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react";
import { Button, Input } from "@/components/ui";
interface Row {
/** Stable across edits, so deleting a row doesn't shift the reveal state
* (or input focus) onto its neighbour. */
id: string;
key: string;
value: string;
}
const SENSITIVE_RE = /(PASS|SECRET|TOKEN|KEY|APIKEY|PWD|CREDENTIAL)/i;
let nextRowId = 0;
const newRow = (key = "", value = ""): Row => ({ id: `r${nextRowId++}`, key, value });
function parseEnv(text: string): Row[] {
return text
.split("\n")
.filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("="))
.map((l) => {
const idx = l.indexOf("=");
return newRow(l.slice(0, idx).trim(), l.slice(idx + 1));
});
}
function serialize(rows: Row[]): string {
const named = rows.filter((r) => r.key.trim());
return named.map((r) => `${r.key.trim()}=${r.value}`).join("\n") + (named.length ? "\n" : "");
}
const QUICK = [
{ key: "PUID", value: "1000" },
{ key: "PGID", value: "1000" },
{ key: "TZ", value: "Europe/Berlin" },
];
export function EnvEditor({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [mode, setMode] = useState<"table" | "raw">("table");
const [reveal, setReveal] = useState<Record<string, boolean>>({});
// The rows are owned here rather than derived from `value` on every render:
// serialize() drops rows with an empty key, so a freshly added (still blank)
// row would be thrown away before it could ever be typed into — which is why
// "Add variable" appeared to do nothing.
const [rows, setRows] = useState<Row[]>(() => parseEnv(value));
const lastEmitted = useRef(value);
useEffect(() => {
// Only re-parse when `value` changed somewhere else (loading a stack,
// editing in raw mode); echoes of our own edits must not clobber blank rows.
if (value !== lastEmitted.current) {
lastEmitted.current = value;
setRows(parseEnv(value));
}
}, [value]);
const update = (next: Row[]) => {
setRows(next);
const text = serialize(next);
lastEmitted.current = text;
onChange(text);
};
const setRow = (id: string, patch: Partial<Row>) =>
update(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const addRow = (row?: { key: string; value: string }) =>
update([...rows, row ? newRow(row.key, row.value) : newRow()]);
const delRow = (id: string) => update(rows.filter((r) => r.id !== id));
const tabClass = (active: boolean) =>
active
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600";
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-2 flex items-center gap-2">
<button onClick={() => setMode("table")} className={tabClass(mode === "table")}>
<Table className="h-3.5 w-3.5" /> Table
</button>
<button onClick={() => setMode("raw")} className={tabClass(mode === "raw")}>
<FileText className="h-3.5 w-3.5" /> Raw
</button>
{mode === "table" && (
<div className="ml-auto flex gap-1">
{QUICK.map((q) => (
<button
key={q.key}
onClick={() => addRow(q)}
className="rounded border border-slate-300 px-2 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
+ {q.key}
</button>
))}
</div>
)}
</div>
{mode === "raw" ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
// min-h-0 lets flex-1 shrink it past a textarea's intrinsic two-row
// height, so it fills the pane instead of dictating it.
className="min-h-0 flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs dark:border-slate-600 dark:bg-slate-800"
placeholder="KEY=value"
/>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs text-slate-500">
<tr>
<th className="pb-1">Key</th>
<th className="pb-1">Value</th>
<th className="w-10 pb-1" />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const sensitive = SENSITIVE_RE.test(r.key);
const masked = sensitive && !reveal[r.id];
return (
<tr key={r.id}>
<td className="py-1 pr-2">
<Input
value={r.key}
onChange={(e) => setRow(r.id, { key: e.target.value })}
placeholder="KEY"
/>
</td>
<td className="py-1 pr-2">
<div className="flex items-center gap-1">
<Input
type={masked ? "password" : "text"}
value={r.value}
onChange={(e) => setRow(r.id, { value: e.target.value })}
placeholder="value"
/>
{sensitive && (
<button
onClick={() => setReveal((s) => ({ ...s, [r.id]: !s[r.id] }))}
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
title={masked ? "Reveal" : "Hide"}
>
{masked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
)}
</div>
</td>
<td className="py-1">
<button
onClick={() => delRow(r.id)}
className="rounded p-1.5 text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Remove variable"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{rows.length === 0 && (
<p className="py-3 text-sm text-slate-500">
No variables yet Add variable starts a blank row.
</p>
)}
</div>
{/* Outside the scroll area: with a long list the button would
otherwise sit below the fold. */}
<Button variant="outline" className="mt-2 self-start" onClick={() => addRow()}>
<Plus className="h-4 w-4" /> Add variable
</Button>
</div>
)}
</div>
);
}