import { useMemo, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
LuChevronDown,
LuGripVertical,
LuMaximize2,
LuMinimize2,
LuPlay,
LuTrash2,
} from "react-icons/lu";
import { useDryRunScript, useScript } from "../../api/hooks.js";
import { errorMessage } from "../../api/client.js";
import { CodeEditor } from "../CodeEditor.jsx";
import { FormInput, FormSelect, FormTextarea } from "../FormControls.jsx";
import { JsonTree } from "../JsonViewBlock.jsx";
import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format";
import { prettyJson } from "../../lib/script.js";
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
import { needsMode, stepCustomName, stepDisplayName } from "../../lib/workflow-doc.js";
import {
stepPredecessors,
stepSuccessors,
} from "../../lib/workflow-graph.js";
import { ConfigFields } from "./ConfigFields.jsx";
import {
ConfigTooltip,
configValueText,
FieldLabel,
previewConfigValue,
SchemaTooltip,
} from "./FieldHelp.jsx";
import { ScriptIcon } from "../ScriptIcon.jsx";
import {
configHasOverlay,
mergeProfileConfig,
overlayFromMerged,
} from "../../lib/profile.js";
export function ScriptCard(props) {
if (props.sortable === false) {
return ;
}
return ;
}
function ScriptCardSortable(props) {
const drag = useSortable({
id: props.step.uiId,
disabled: props.disabled,
});
return ;
}
function ScriptCardView({
step,
index,
otherSteps,
scriptsByName,
profilesByName,
onChange,
onRemove,
disabled,
workflows,
owner,
excludeFile,
sortable = true,
defaultExpanded = false,
trySession,
onTrySuccess,
tryOpen: tryOpenProp,
onTryOpenChange,
onNavigateTry,
drag,
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = drag ?? {
attributes: {},
listeners: {},
setNodeRef: undefined,
transform: null,
transition: undefined,
isDragging: false,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const tryControlled = typeof onTryOpenChange === "function";
const [localTryOpen, setLocalTryOpen] = useState(false);
const tryOpen = tryControlled ? Boolean(tryOpenProp) : localTryOpen;
function setTryOpen(open) {
if (tryControlled) onTryOpenChange(open);
else setLocalTryOpen(open);
}
const [expanded, setExpanded] = useState(defaultExpanded);
const [configMode, setConfigMode] = useState("form");
const profile = step.profile && profilesByName?.get(step.profile);
const scriptName = profile?.script || step.script;
const listed = scriptsByName?.get(scriptName);
const meta = listed?.meta ?? null;
const inheritedConfig = profile?.config ?? null;
const mergedConfig = profile
? mergeProfileConfig(profile.config, step.config)
: step.config;
const overridden = Boolean(step.profile && configHasOverlay(step.config));
const duplicateId =
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
const scriptFallback = step.kind === "set" ? "set" : scriptName || "untitled";
const customName = stepCustomName(step);
const baseName = stepDisplayName(step, scriptFallback);
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
const setConfig =
step.kind === "set" ? { expression: step.expression ?? "" } : null;
const missingProfile = Boolean(step.profile) && !profile;
const neighborOpts = useMemo(() => {
const steps = otherSteps ?? [];
const byUi = new Map(steps.map((s, i) => [s.uiId, { step: s, index: i }]));
function labeled(uiIds) {
return uiIds
.map((uiId) => {
const hit = byUi.get(uiId);
if (!hit) return null;
return {
uiId,
label: stepTryLabel(hit.step, hit.index),
};
})
.filter(Boolean);
}
return {
predecessors: labeled(stepPredecessors(steps, step.uiId)),
successors: labeled(stepSuccessors(steps, step.uiId)),
};
}, [otherSteps, step.uiId]);
return (
{sortable !== false ? (
) : null}
{step.kind === "script" && scriptName ? (
) : null}
{step.kind === "script" ? (
) : null}
{!expanded ? (
) : null}
{step.kind === "set" || (step.kind === "script" && scriptName) ? (
) : null}
{expanded ? (
<>
{step.kind === "set" ? (
onChange({ ...step, expression: e.target.value })
}
/>
) : (
{step.profile ? (
JSON is the step overlay, not the merged runtime config
) : null}
{configMode === "form" ? (
onChange({ ...step, config })}
/>
) : (
onChange({ ...step, config })}
/>
)}
)}
Advanced
>
) : null}
{tryOpen ? (
onChange({ ...step, expression })
: (config) => onChange({ ...step, config })
}
onClose={() => setTryOpen(false)}
/>
) : null}
);
}
function parseConfigObject(text) {
const parsed = JSON.parse(text || "{}");
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("config must be a JSON object");
}
return parsed;
}
function configsEqual(a, b) {
try {
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
} catch {
return false;
}
}
function ConfigJsonEditor({ config, disabled, onChange }) {
const [draft, setDraft] = useState(() => prettyJson(config ?? {}) || "{}");
const [parseError, setParseError] = useState(null);
function commit(nextText) {
setDraft(nextText);
try {
const parsed = parseConfigObject(nextText);
setParseError(null);
if (!configsEqual(parsed, config ?? {})) {
onChange(parsed);
}
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
}
}
return (
{parseError ?
{parseError}
: null}
{} : commit}
readOnly={disabled}
height="100%"
/>
);
}
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
const mode = needsMode(step.needs);
const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId);
const ids = others.map((s) => s.id);
const nameById = new Map(
others.map((s) => [s.id, stepCustomName(s) || null]),
);
function setMode(next) {
if (next === "none") onChange({ ...step, needs: null });
else if (next === "list") onChange({ ...step, needs: [], when: "" });
else onChange({ ...step, needs: {}, when: "" });
}
function idLabel(id) {
const named = nameById.get(id);
return named ? `${id} · ${named}` : id;
}
return (
needs
setMode(e.target.value)}
>
{mode === "list" ? (
{ids.length === 0 ? (
Give other steps an id first
) : (
ids.map((id) => {
const checked =
Array.isArray(step.needs) && step.needs.includes(id);
return (
);
})
)}
) : null}
{mode === "map" ? (
onChange({ ...step, needs })}
/>
) : null}
);
}
function NeedsMap({ needs, ids, idLabel, disabled, onChange }) {
const entries = Object.entries(needs ?? {});
return (
{entries.map(([alias, from]) => (
{
const next = {};
for (const [k, v] of Object.entries(needs)) {
next[k === alias ? e.target.value : k] = v;
}
onChange(next);
}}
/>
onChange({ ...needs, [alias]: e.target.value })}
>
{from && !ids.includes(from) ? (
) : null}
{ids.map((id) => (
))}
))}
);
}
function TryNavCluster({ direction, neighbors, pick, onPick, onGo, disabled }) {
if (!neighbors?.length) return null;
const isPrev = direction === "prev";
if (neighbors.length === 1) {
const only = neighbors[0];
return (
);
}
const selected =
neighbors.some((n) => n.uiId === pick) ? pick : neighbors[0].uiId;
return (
onPick(e.target.value)}
aria-label={isPrev ? "Previous step" : "Next step"}
>
{neighbors.map((n) => (
))}
);
}
function ScriptTryDialog({
kind = "script",
script,
expression: initialExpression = "",
config,
inheritedConfig,
meta,
owner,
disabled,
step,
index,
otherSteps,
trySession,
onTrySuccess,
predecessors = [],
successors = [],
onNavigateTry,
onApplyConfig,
onClose,
}) {
const isSet = kind === "set";
const existing = useScript(script, !isSet);
const dryRun = useDryRunScript();
const seed = useMemo(
() =>
seedTryDialog({
step: step ?? { uiId: script },
index: index ?? 0,
steps: otherSteps ?? [],
session: trySession ?? { byStep: {}, lastTriedUiId: null },
meta: isSet ? null : meta,
}),
[step, index, otherSteps, trySession, meta, isSet, script],
);
const [dataJson, setDataJson] = useState(() => prettyJson(seed.data ?? {}) || "{}");
const [contextJson, setContextJson] = useState(
() => prettyJson(seed.context ?? {}) || "{}",
);
const [configJson, setConfigJson] = useState(
() => prettyJson(config ?? {}) || "{}",
);
const [expression, setExpression] = useState(() => initialExpression ?? "");
const [baselineMerged, setBaselineMerged] = useState(() => ({
...(config ?? {}),
}));
const [baselineExpression, setBaselineExpression] = useState(
() => initialExpression ?? "",
);
const [parseError, setParseError] = useState(null);
const [expanded, setExpanded] = useState(false);
const [seedHint] = useState(() => seed.source);
const [sessionResult, setSessionResult] = useState(() => seed.lastResult ?? null);
const [prevPick, setPrevPick] = useState(
() => predecessors[0]?.uiId ?? "",
);
const [nextPick, setNextPick] = useState(
() => successors[0]?.uiId ?? "",
);
const resultValue = useMemo(() => {
if (dryRun.data) {
if (dryRun.data.status === "success") {
return {
output: dryRun.data.output,
context: dryRun.data.context,
...(dryRun.data.skipRemaining ? { skipRemaining: true } : {}),
};
}
return { error: dryRun.data.error };
}
if (sessionResult) {
return {
output: sessionResult.output,
context: sessionResult.context,
};
}
return undefined;
}, [dryRun.data, sessionResult]);
function parseTryConfig() {
return parseConfigObject(configJson);
}
const configParse = useMemo(() => {
if (isSet) return { ok: true, value: { expression } };
try {
return { ok: true, value: parseConfigObject(configJson) };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}, [configJson, isSet, expression]);
const configDirty = isSet
? expression !== baselineExpression
: configParse.ok && !configsEqual(configParse.value, baselineMerged);
const canApply =
Boolean(onApplyConfig) && !disabled && configParse.ok && configDirty;
function onRun() {
setParseError(null);
let data;
let context;
let runConfig;
try {
data = JSON.parse(dataJson || "null");
context = JSON.parse(contextJson || "{}");
if (!isSet) runConfig = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
if (
context != null &&
(typeof context !== "object" || Array.isArray(context))
) {
setParseError("context must be a JSON object");
return;
}
if (isSet) {
if (!expression.trim()) {
setParseError("expression is required");
return;
}
dryRun.mutate(
{
name: "set",
expression,
data,
context: context ?? {},
config: { expression },
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
return;
}
if (!existing.data?.content) {
setParseError("could not load script source");
return;
}
dryRun.mutate(
{
name: script,
content: existing.data.content,
data,
context: context ?? {},
config: runConfig,
owner,
},
{
onSuccess: (res) => {
if (res?.status !== "success") return;
setSessionResult({
output: res.output,
context: res.context ?? {},
});
onTrySuccess?.(step?.uiId, {
data,
context: context ?? {},
output: res.wireOutput ?? res.output,
resultContext: res.wireContext ?? res.context ?? {},
});
},
},
);
}
function onApply() {
setParseError(null);
if (isSet) {
onApplyConfig?.(expression);
setBaselineExpression(expression);
return;
}
let parsed;
try {
parsed = parseTryConfig();
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
return;
}
const overlay = overlayFromMerged(inheritedConfig, parsed);
onApplyConfig?.(overlay);
setBaselineMerged(parsed);
}
const runDisabled =
dryRun.isPending ||
(!isSet && (existing.isLoading || !configParse.ok)) ||
(isSet && !expression.trim());
return (
);
}