Files
hyperframes/packages/studio/src/components/panels/VariablesPanel.tsx
T

555 lines
19 KiB
TypeScript

import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
import type {
Composition,
CompositionVariable,
VariableUsageReport,
VariableValidationIssue,
} from "@hyperframes/sdk";
import type { EditHistoryKind } from "../../utils/editHistory";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
import { VariablesOtherCompositions } from "./VariablesOtherCompositions";
import { RowAction } from "./VariablesRowAction";
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
import {
DeclarationForm,
draftFromDeclaration,
mergeDeclarationEdit,
EMPTY_DRAFT,
} from "./VariablesDeclarationForm";
import { PreviewValueControl } from "./VariablesValueControls";
import { copyTextToClipboard } from "../../utils/clipboard";
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
/** POSIX single-quote escaping so the copied command survives quotes in values. */
function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
interface VariablesPanelProps {
sdkSession: Composition | null;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
}
function formatIssue(issue: VariableValidationIssue): string {
switch (issue.kind) {
case "undeclared":
return `"${issue.variableId}" is not declared.`;
case "type-mismatch":
return `"${issue.variableId}" expects ${issue.expected}, got ${issue.actual}.`;
case "enum-out-of-range":
return `"${issue.variableId}" must be one of: ${issue.allowed.join(", ")}.`;
}
}
function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
if (issues.length === 0) return null;
return (
<div className="space-y-1 rounded-lg border border-red-900/60 bg-red-950/30 p-2">
{issues.map((issue) => (
<p key={`${issue.kind}:${issue.variableId}`} className="text-[10px] text-red-300">
{formatIssue(issue)}
</p>
))}
</div>
);
}
// fallow-ignore-next-line complexity
function VariableRow({
decl,
value,
overridden,
unused,
editing,
onCommitPreview,
onSetDefault,
onToggleEdit,
onSaveEdit,
onRemove,
}: {
decl: CompositionVariable;
value: unknown;
overridden: boolean;
unused: boolean;
editing: boolean;
onCommitPreview: (value: unknown) => void;
onSetDefault: (value: string | number | boolean) => void;
onToggleEdit: () => void;
onSaveEdit: (decl: CompositionVariable) => void;
onRemove: () => void;
}) {
return (
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
<div className="flex items-center gap-1.5">
<span className="truncate text-[10px] font-medium text-neutral-300">{decl.label}</span>
<span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
{decl.type}
</span>
{unused && (
<span
className="rounded bg-amber-900/40 px-1 py-px text-[8px] text-amber-400"
title="No script reads this variable"
>
unused
</span>
)}
{overridden && <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" />}
<span className="ml-auto flex items-center gap-1">
{overridden && isScalar(value) && (
<RowAction
label="Set default"
title="Persist this value as the declared default"
onClick={() => onSetDefault(value)}
/>
)}
<RowAction label="Edit" title="Edit declaration" onClick={onToggleEdit} />
<RowAction label="✕" title="Remove declaration" danger onClick={onRemove} />
</span>
</div>
{decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
{editing ? (
<DeclarationForm
initial={draftFromDeclaration(decl)}
submitLabel="Save"
onSubmit={(edited) => onSaveEdit(mergeDeclarationEdit(decl, edited))}
onCancel={onToggleEdit}
/>
) : (
<PreviewValueControl decl={decl} value={value} onCommit={onCommitPreview} />
)}
</div>
);
}
function UndeclaredReads({
usage,
onDeclare,
}: {
usage: VariableUsageReport | null;
onDeclare: (id: string) => void;
}) {
if (!usage || usage.undeclaredReads.length === 0) return null;
return (
<div className="space-y-1 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
Read by scripts, not declared
</p>
{usage.undeclaredReads.map((id) => (
<div key={id} className="flex items-center gap-2">
<code className="font-mono text-[10px] text-neutral-400">{id}</code>
<RowAction
label="Declare"
title="Declare as a string variable"
onClick={() => onDeclare(id)}
/>
</div>
))}
</div>
);
}
/** Preview-state pill + reset, shown in the panel header. */
function PreviewModeHeader({
overrideCount,
onReset,
}: {
overrideCount: number;
onReset: () => void;
}) {
const hasOverrides = overrideCount > 0;
return (
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-neutral-200">Variables</span>
<span
className={`rounded-full px-2 py-0.5 text-[9px] font-medium ${
hasOverrides
? "bg-studio-accent/20 text-studio-accent"
: "bg-neutral-800 text-neutral-500"
}`}
>
{hasOverrides ? `Previewing ${overrideCount} custom` : "Previewing defaults"}
</span>
</div>
{hasOverrides && (
<button
type="button"
onClick={onReset}
className="h-6 rounded px-2 text-[10px] text-neutral-400 hover:text-neutral-200"
>
Reset
</button>
)}
</div>
);
}
/**
* Developer/agent handoff: copy the effective values as JSON or as a
* ready-to-run render command mirroring exactly what the preview shows.
*/
function HandoffFooter({
effectiveValues,
compPath,
onCopy,
}: {
effectiveValues: Record<string, unknown>;
compPath: string;
onCopy: (text: string, what: string) => void;
}) {
const json = JSON.stringify(effectiveValues);
const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`;
return (
<div className="space-y-1.5 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
Use this template
</p>
<code className="block truncate font-mono text-[9px] text-neutral-500" title={command}>
{command}
</code>
<div className="flex items-center gap-2">
<RowAction
label="Copy render command"
title="CLI command rendering exactly what the preview shows"
onClick={() => onCopy(command, "Render command")}
/>
<RowAction
label="Copy values JSON"
title="Effective values (defaults merged with preview overrides)"
onClick={() => onCopy(json, "Values JSON")}
/>
</div>
</div>
);
}
const EMPTY_STATE = (
<p className="text-[10px] leading-relaxed text-neutral-500">
No variables declared. Variables make parts of this composition dynamic — declare them here (or
in <code className="font-mono">data-composition-variables</code>), read them with{" "}
<code className="font-mono">getVariables()</code>, and pass values at render time with{" "}
<code className="font-mono">--variables</code>.
</p>
);
// Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
// fallow-ignore-next-line complexity
export const VariablesPanel = memo(function VariablesPanel({
sdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
}: VariablesPanelProps) {
const { activeCompPath, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
const { domEditSelection } = useDomEditContext();
// On the master view (no activeCompPath) the panel targets the project's real
// main composition — the first .html in the tree — not a hardcoded index.html
// that may not exist. This same path is used for the persist write target (so
// an edit never lands in a phantom index.html) AND the handoff render command.
// Null only when the project has no composition yet, in which case sdkSession
// is also null and the panel is inert.
const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree);
const previewValues = usePreviewVariablesStore((s) => s.values);
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
// Bumped after each persisted schema edit so declarations re-derive without
// waiting for the session reload round-trip.
const [revision, setRevision] = useState(0);
// Also bump on any session mutation (undo/redo, edits dispatched by other
// panels or agents) — the memos below must never trust refreshKey alone.
useEffect(() => {
if (!sdkSession) return;
return sdkSession.on("change", () => setRevision((r) => r + 1));
}, [sdkSession]);
const [addOpen, setAddOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const persistVariables = useVariablesPersist({
sdkSession,
activeCompPath: effectiveCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
});
const declarations = useMemo(
() => sdkSession?.getVariableDeclarations() ?? [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, refreshKey, revision],
);
const usage = useMemo(
() => sdkSession?.getVariableUsage() ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, refreshKey, revision],
);
const issues = useMemo(
() => (previewValues && sdkSession ? sdkSession.validateVariableValues(previewValues) : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, previewValues, refreshKey, revision],
);
const effectiveValues = useMemo(
() => sdkSession?.getVariableValues(previewValues ?? undefined) ?? {},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sdkSession, previewValues, refreshKey, revision],
);
const copyToClipboard = useCallback(
(text: string, what: string) => {
// Shared helper carries the execCommand fallback Safari needs.
void copyTextToClipboard(text).then((ok) =>
showToast(
ok ? `${what} copied` : `Couldn't copy ${what.toLowerCase()}`,
ok ? "info" : "error",
),
);
},
[showToast],
);
const dropPreviewOverride = useCallback(
(id: string) => {
if (previewValues && id in previewValues) {
const next = { ...previewValues };
delete next[id];
setPreviewValues(next);
}
},
[previewValues, setPreviewValues],
);
const commitPreviewValue = useCallback(
(id: string, value: unknown, declDefault: unknown) => {
const next = { ...(previewValues ?? {}) };
if (JSON.stringify(value) === JSON.stringify(declDefault)) {
delete next[id];
} else {
next[id] = value;
}
setPreviewValues(next);
reloadPreview();
},
[previewValues, setPreviewValues, reloadPreview],
);
const runSchemaEdit = useCallback(
async (label: string, mutate: (session: Composition) => void): Promise<boolean> => {
try {
const changed = await persistVariables(label, mutate);
if (changed) setRevision((r) => r + 1);
else showToast(`${label}: no change applied`, "info");
return changed;
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
return false;
}
},
[persistVariables, showToast],
);
const handleAdd = useCallback(
(decl: CompositionVariable) => {
if (!sdkSession) return;
const check = sdkSession.can({ type: "declareVariable", declaration: decl });
if (!check.ok) {
showToast(check.message, "error");
return;
}
setAddOpen(false);
void runSchemaEdit(`Declare variable "${decl.id}"`, (s) => s.declareVariable(decl));
},
[sdkSession, runSchemaEdit, showToast],
);
const handleUpdate = useCallback(
(decl: CompositionVariable) => {
if (!sdkSession) return;
const check = sdkSession.can({
type: "updateVariableDeclaration",
id: decl.id,
declaration: decl,
});
if (!check.ok) {
showToast(check.message, "error");
return;
}
setEditingId(null);
void runSchemaEdit(`Edit variable "${decl.id}"`, (s) =>
s.updateVariableDeclaration(decl.id, decl),
);
},
[sdkSession, runSchemaEdit, showToast],
);
const handleRemove = useCallback(
(id: string) => {
if (!sdkSession) return;
const check = sdkSession.can({ type: "removeVariableDeclaration", id });
if (!check.ok) {
showToast(check.message, "error");
return;
}
// Drop the preview override only if the declaration was actually removed —
// otherwise a rejected/failed edit would leave the row on disk but silently
// wipe the user's custom preview value.
void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id)).then(
(changed) => {
if (changed) dropPreviewOverride(id);
},
);
},
[sdkSession, runSchemaEdit, dropPreviewOverride, showToast],
);
const handleSetDefault = useCallback(
(id: string, value: string | number | boolean) => {
void runSchemaEdit(`Set default for "${id}"`, (s) => s.setVariableValue(id, value));
// The override now equals the persisted default — drop it from preview state.
dropPreviewOverride(id);
},
[runSchemaEdit, dropPreviewOverride],
);
const resetPreview = useCallback(() => {
setPreviewValues(null);
reloadPreview();
}, [setPreviewValues, reloadPreview]);
const handleBind = useCallback(
// Guard chain (session, selection, type-compat) — one branch per guard.
// fallow-ignore-next-line complexity
(action: BindAction, id: string) => {
if (!sdkSession || !domEditSelection?.hfId) return;
// Binding to an existing variable is allowed, but only when the types
// agree — wiring a color style to a string variable silently breaks
// the element's styling.
const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id);
const wanted = action.declaration(id).type;
if (existing && existing.type !== wanted) {
showToast(
`"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`,
"error",
);
return;
}
const hfId = domEditSelection.hfId;
void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) =>
applyBind(s, hfId, action, id),
);
},
[sdkSession, domEditSelection, runSchemaEdit, showToast],
);
// The bind gesture targets the composition the session models — a selection
// from another source file must not write bindings into this one.
const bindableSelection =
domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html")
? domEditSelection
: null;
if (!sdkSession) {
return (
<div className="flex h-full items-center justify-center px-6 text-center">
<p className="text-xs text-neutral-500">Open a composition to manage its variables.</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<PreviewModeHeader
overrideCount={previewValues ? Object.keys(previewValues).length : 0}
onReset={resetPreview}
/>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
{bindableSelection && (
<VariablesBindElement
key={bindableSelection.hfId}
selection={bindableSelection}
sdkSession={sdkSession}
onBind={handleBind}
/>
)}
<ValidationStrip issues={issues} />
{declarations.length === 0 && !addOpen && EMPTY_STATE}
{/* fallow-ignore-next-line complexity */}
{declarations.map((decl) => (
<VariableRow
key={decl.id}
decl={decl}
value={
previewValues && decl.id in previewValues ? previewValues[decl.id] : decl.default
}
overridden={previewValues !== null && decl.id in previewValues}
unused={
usage !== null && !usage.scanIncomplete && usage.unusedDeclarations.includes(decl.id)
}
editing={editingId === decl.id}
onCommitPreview={(v) => commitPreviewValue(decl.id, v, decl.default)}
onSetDefault={(v) => handleSetDefault(decl.id, v)}
onToggleEdit={() => setEditingId(editingId === decl.id ? null : decl.id)}
onSaveEdit={handleUpdate}
onRemove={() => handleRemove(decl.id)}
/>
))}
<UndeclaredReads
usage={usage}
onDeclare={(id) => handleAdd({ id, type: "string", label: id, default: "" })}
/>
{usage?.scanIncomplete && (
<p className="text-[9px] text-neutral-600">
Scripts access variables dynamically — usage info may be incomplete.
</p>
)}
{declarations.length > 0 && (
<HandoffFooter
effectiveValues={effectiveValues}
compPath={effectiveCompPath ?? "index.html"}
onCopy={copyToClipboard}
/>
)}
{addOpen ? (
<DeclarationForm
initial={EMPTY_DRAFT}
submitLabel="Add variable"
onSubmit={handleAdd}
onCancel={() => setAddOpen(false)}
/>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="h-7 w-full rounded-lg border border-dashed border-neutral-800 text-[10px] font-medium text-neutral-500 transition-colors hover:border-neutral-700 hover:text-neutral-300"
>
+ Add variable
</button>
)}
<VariablesOtherCompositions
fileTree={fileTree}
excludePath={activeCompPath ?? "index.html"}
refreshKey={`${refreshKey}:${revision}`}
readProjectFile={readProjectFile}
writeProjectFile={writeProjectFile}
recordEdit={recordEdit}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
/>
</div>
</div>
);
});