Fix "Add variable" doing nothing, and size the editors to the viewport (0.42.2)
CI / build-and-push (push) Successful in 1m45s
CI / build-and-push (push) Successful in 1m45s
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
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.42.1"
|
APP_VERSION = "0.42.2"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.42.1",
|
"version": "0.42.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+112
-80
@@ -1,30 +1,33 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react";
|
import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react";
|
||||||
import { Button, Input } from "@/components/ui";
|
import { Button, Input } from "@/components/ui";
|
||||||
|
|
||||||
interface Row {
|
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;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SENSITIVE_RE = /(PASS|SECRET|TOKEN|KEY|APIKEY|PWD|CREDENTIAL)/i;
|
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[] {
|
function parseEnv(text: string): Row[] {
|
||||||
return text
|
return text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("="))
|
.filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("="))
|
||||||
.map((l) => {
|
.map((l) => {
|
||||||
const idx = l.indexOf("=");
|
const idx = l.indexOf("=");
|
||||||
return { key: l.slice(0, idx).trim(), value: l.slice(idx + 1) };
|
return newRow(l.slice(0, idx).trim(), l.slice(idx + 1));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(rows: Row[]): string {
|
function serialize(rows: Row[]): string {
|
||||||
return rows
|
const named = rows.filter((r) => r.key.trim());
|
||||||
.filter((r) => r.key.trim())
|
return named.map((r) => `${r.key.trim()}=${r.value}`).join("\n") + (named.length ? "\n" : "");
|
||||||
.map((r) => `${r.key.trim()}=${r.value}`)
|
|
||||||
.join("\n")
|
|
||||||
.concat(rows.length ? "\n" : "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK = [
|
const QUICK = [
|
||||||
@@ -41,37 +44,49 @@ export function EnvEditor({
|
|||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [mode, setMode] = useState<"table" | "raw">("table");
|
const [mode, setMode] = useState<"table" | "raw">("table");
|
||||||
const [reveal, setReveal] = useState<Record<number, boolean>>({});
|
const [reveal, setReveal] = useState<Record<string, boolean>>({});
|
||||||
const rows = useMemo(() => parseEnv(value), [value]);
|
|
||||||
|
|
||||||
const update = (next: Row[]) => onChange(serialize(next));
|
// 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);
|
||||||
|
|
||||||
const setRow = (i: number, patch: Partial<Row>) =>
|
useEffect(() => {
|
||||||
update(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
// Only re-parse when `value` changed somewhere else (loading a stack,
|
||||||
const addRow = (row: Row = { key: "", value: "" }) => update([...rows, row]);
|
// editing in raw mode); echoes of our own edits must not clobber blank rows.
|
||||||
const delRow = (i: number) => update(rows.filter((_, idx) => idx !== i));
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<button
|
<button onClick={() => setMode("table")} className={tabClass(mode === "table")}>
|
||||||
onClick={() => setMode("table")}
|
|
||||||
className={
|
|
||||||
mode === "table"
|
|
||||||
? "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"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table className="h-3.5 w-3.5" /> Table
|
<Table className="h-3.5 w-3.5" /> Table
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => setMode("raw")} className={tabClass(mode === "raw")}>
|
||||||
onClick={() => setMode("raw")}
|
|
||||||
className={
|
|
||||||
mode === "raw"
|
|
||||||
? "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"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5" /> Raw
|
<FileText className="h-3.5 w-3.5" /> Raw
|
||||||
</button>
|
</button>
|
||||||
{mode === "table" && (
|
{mode === "table" && (
|
||||||
@@ -94,60 +109,77 @@ export function EnvEditor({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="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"
|
// 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"
|
placeholder="KEY=value"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<table className="w-full text-sm">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<thead className="text-left text-xs text-slate-500">
|
<table className="w-full text-sm">
|
||||||
<tr>
|
<thead className="text-left text-xs text-slate-500">
|
||||||
<th className="pb-1">Key</th>
|
<tr>
|
||||||
<th className="pb-1">Value</th>
|
<th className="pb-1">Key</th>
|
||||||
<th className="pb-1 w-10" />
|
<th className="pb-1">Value</th>
|
||||||
</tr>
|
<th className="w-10 pb-1" />
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{rows.map((r, i) => {
|
<tbody>
|
||||||
const sensitive = SENSITIVE_RE.test(r.key);
|
{rows.map((r) => {
|
||||||
const masked = sensitive && !reveal[i];
|
const sensitive = SENSITIVE_RE.test(r.key);
|
||||||
return (
|
const masked = sensitive && !reveal[r.id];
|
||||||
<tr key={i}>
|
return (
|
||||||
<td className="pr-2 py-1">
|
<tr key={r.id}>
|
||||||
<Input value={r.key} onChange={(e) => setRow(i, { key: e.target.value })} />
|
<td className="py-1 pr-2">
|
||||||
</td>
|
|
||||||
<td className="pr-2 py-1">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
<Input
|
||||||
type={masked ? "password" : "text"}
|
value={r.key}
|
||||||
value={r.value}
|
onChange={(e) => setRow(r.id, { key: e.target.value })}
|
||||||
onChange={(e) => setRow(i, { value: e.target.value })}
|
placeholder="KEY"
|
||||||
/>
|
/>
|
||||||
{sensitive && (
|
</td>
|
||||||
<button
|
<td className="py-1 pr-2">
|
||||||
onClick={() => setReveal((s) => ({ ...s, [i]: !s[i] }))}
|
<div className="flex items-center gap-1">
|
||||||
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
|
<Input
|
||||||
title={masked ? "Reveal" : "Hide"}
|
type={masked ? "password" : "text"}
|
||||||
>
|
value={r.value}
|
||||||
{masked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
onChange={(e) => setRow(r.id, { value: e.target.value })}
|
||||||
</button>
|
placeholder="value"
|
||||||
)}
|
/>
|
||||||
</div>
|
{sensitive && (
|
||||||
</td>
|
<button
|
||||||
<td className="py-1">
|
onClick={() => setReveal((s) => ({ ...s, [r.id]: !s[r.id] }))}
|
||||||
<button
|
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
onClick={() => delRow(i)}
|
title={masked ? "Reveal" : "Hide"}
|
||||||
className="rounded p-1.5 text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
>
|
||||||
>
|
{masked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
<Trash2 className="h-4 w-4" />
|
</button>
|
||||||
</button>
|
)}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
);
|
<td className="py-1">
|
||||||
})}
|
<button
|
||||||
</tbody>
|
onClick={() => delRow(r.id)}
|
||||||
</table>
|
className="rounded p-1.5 text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
<Button variant="outline" className="mt-2" onClick={() => addRow()}>
|
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
|
<Plus className="h-4 w-4" /> Add variable
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,7 +169,11 @@ export function StackEditor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-3">
|
// AppShell's <main> is content-height, so `h-full` here would collapse to
|
||||||
|
// auto and leave the editors at their intrinsic (tiny) size. Size against
|
||||||
|
// the viewport instead, minus the top bar (pt-[76px]) and the page's
|
||||||
|
// bottom padding (pb-10), so the editor fills whatever screen the user has.
|
||||||
|
<div className="flex h-[calc(100vh-116px)] min-h-[420px] flex-col space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
|
|||||||
Reference in New Issue
Block a user