Files
feuerwerk/src/core/commands/variableCommands.ts
T
adminandClaude Opus 5.5 fb981b5b0f Let the agent copy nodes, also into another graph (#434)
The agent built every part again that already stood in a graph,
node by node, while the editor has Ctrl+C and Ctrl+V for it. The new
command `copy_nodes` in the area graphs copies nodes into the same
graph or another one, in one undo step with the rest of the call.

- The links between the copied nodes come along, and so do groups
  and comments that are only about copied nodes. A node belongs to a
  group by its middle, as when it is dragged. `boxesAbout` in
  annotationFollow.ts decides this.
- `made` names each copy by the id of its original, so later steps
  link to it with `$copy.<id>`. `$copy` alone is the first one.
- Without x and y the copies are arranged at the end of the call like
  new nodes. With them they keep their layout, top left corner there.
- Nodes the target graph does not take are refused by name, not left
  out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and
  `fittingPart` moved from clipboard.ts to paletteEntries.ts.
- A copied Variables node takes the variables of its behavior along
  into the target behavior (`CarryVariablesCommand`), with default,
  value, Keep and Private. A variable of that name of another kind
  there is refused.
- The relay's instructions name `copy_nodes`.

Found on the way:
- Groups made by the agent can take in a node of another group once
  the nodes are arranged. lint_graph does not count that.
- Undo after a paste leaves an empty `groups: []`, as
  AddAnnotationCommand does. It is the same as no list.

Tests:
- agent-copy (new, 3 tests) with the agent, relay, clipboard and
  palette tests: 118/118.
- Counter-probes: 6 bendings, all compile, all red.
- e2e 71 (atx): ALL PASSED, 26 checks.
- Gate (atx): CHECK EXIT 0, 327 files, 5024 tests.
- Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new
  behavior, 6 links, group, comment and speed (300) along, lint_graph
  0 everywhere, taken back with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:39:37 +02:00

550 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type FieldValue,
isStruct,
type PropertyBag,
type PropertyValue,
type StructValue,
withFieldRenamed,
withoutField,
} from '../model/Properties';
import {
type BehaviorAsset,
isKeptVariable,
isPrivateVariable,
setKeptVariable,
setPrivateVariable,
type VariableOwnerAsset,
} from '../model/Asset';
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import type { VariableKind } from '../properties/kinds';
import {
emptyFieldValue,
isValidFieldName,
isValidVariableName,
type PropertyDef,
type StructFieldDef,
variableDef,
} from '../properties/PropertySchema';
import { valueAt, withValueAt } from '../properties/valuePath';
import { moveItem, type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError, MoveCommand, RenameCommand } from './Command';
import { renameVariablePorts } from './portCommands';
import { requireBehavior, requireVariableOwner } from './require';
/**
* The variable's default value is written to `props` as well. On a behavior a new variable starts
* at its default every call; "Keep" is switched on by hand (#229).
*/
export class AddVariableCommand implements Command {
readonly label: string;
readonly def: PropertyDef;
constructor(
private readonly assetId: string,
key: string,
kind: VariableKind,
) {
this.def = variableDef(key, kind);
this.label = `Add variable ${key}`;
}
execute(project: Project): void {
const asset = requireVariableOwner(project, this.assetId);
if (!isValidName(asset, this.def.key)) throw new CommandError(INVALID_NAME);
if (asset.variables.some((v) => v.key === this.def.key))
throw new CommandError(`Variable "${this.def.key}" already exists`);
asset.variables.push(this.def);
asset.props[this.def.key] = this.def.default;
}
undo(project: Project): void {
const asset = requireVariableOwner(project, this.assetId);
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
delete asset.props[this.def.key];
}
}
/**
* Declares variables of one behavior in another as they are declared there (#434): default, Keep
* and Private included, for copied nodes that name them. A key the target has already is left alone.
*/
export class CarryVariablesCommand implements Command {
readonly label = 'Carry variables';
private added: string[] = [];
constructor(
private readonly assetId: string,
private readonly fromId: string,
private readonly keys: readonly string[],
) {}
execute(project: Project): void {
const from = requireBehavior(project, this.fromId);
const to = requireBehavior(project, this.assetId);
this.added = [];
for (const key of this.keys) {
const def = from.variables.find((v) => v.key === key);
if (!def) throw new CommandError(`${from.name} has no variable "${key}"`);
if (to.variables.some((v) => v.key === key)) continue;
to.variables.push(structuredClone(def));
to.props[key] = structuredClone(from.props[key] ?? def.default);
if (isKeptVariable(from, key)) setKeptVariable(to, key, true);
if (isPrivateVariable(from, key)) setPrivateVariable(to, key, true);
this.added.push(key);
}
}
undo(project: Project): void {
const to = requireBehavior(project, this.assetId);
const added = new Set(this.added);
to.variables = to.variables.filter((v) => !added.has(v.key));
for (const key of added) {
delete to.props[key];
setKeptVariable(to, key, false);
setPrivateVariable(to, key, false);
}
}
}
/** One placed object's own value for a variable, remembered so that undo can put it back. */
interface TakenOverride {
holderId: string;
objectId: string;
path: string;
value: PropertyValue;
}
function takeOverrides(project: Project, assetId: string, key: string): TakenOverride[] {
const taken: TakenOverride[] = [];
for (const site of overrideSites(project, (id) => id === assetId)) {
const value = site.bag[key];
if (value === undefined) continue;
taken.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
delete site.bag[key];
}
return taken;
}
function restoreOverrides(
project: Project,
assetId: string,
key: string,
taken: readonly TakenOverride[],
): void {
const sites = overrideSites(project, (id) => id === assetId);
for (const o of taken) {
const site = sites.find(
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
);
if (site) site.bag[key] = o.value;
}
}
/** Removes a variable together with its default and every instance override. */
export class RemoveVariableCommand implements Command {
readonly label: string;
private removed?: {
def: Removed<PropertyDef>;
value: PropertyValue | undefined;
kept: boolean;
hidden: boolean;
};
private overrides: TakenOverride[] = [];
constructor(
private readonly assetId: string,
private readonly key: string,
) {
this.label = `Remove variable ${key}`;
}
execute(project: Project): void {
const asset = requireVariableOwner(project, this.assetId);
const def = removeFirst(asset.variables, (v) => v.key === this.key);
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
const kept = asset.type === 'behavior' && isKeptVariable(asset, this.key);
const hidden = isPrivateVariable(asset, this.key);
this.removed = { def, value: asset.props[this.key], kept, hidden };
delete asset.props[this.key];
if (asset.type === 'behavior') {
setKeptVariable(asset, this.key, false);
setPrivateVariable(asset, this.key, false);
}
this.overrides = takeOverrides(project, this.assetId, this.key);
}
undo(project: Project): void {
if (!this.removed) return;
const asset = requireVariableOwner(project, this.assetId);
restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
if (asset.type === 'behavior') {
if (this.removed.kept) setKeptVariable(asset, this.key, true);
if (this.removed.hidden) setPrivateVariable(asset, this.key, true);
}
restoreOverrides(project, this.assetId, this.key, this.overrides);
}
}
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
export class RenameVariableCommand extends RenameCommand {
constructor(
private readonly assetId: string,
from: string,
to: string,
) {
super(`Rename variable ${from}`, from, to);
}
protected rename(project: Project, from: string, to: string): void {
const asset = requireVariableOwner(project, this.assetId);
if (from === to) return;
if (!isValidName(asset, to)) throw new CommandError(INVALID_NAME);
const def = asset.variables.find((v) => v.key === from);
if (!def) throw new CommandError(`Variable "${from}" not found`);
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
def.key = to;
def.label = to;
// Renamed in place, so the switches keep their spot in the list.
if (asset.type === 'behavior') {
if (asset.keptVariables) asset.keptVariables = asset.keptVariables.map((k) => (k === from ? to : k));
if (asset.privateVariables)
asset.privateVariables = asset.privateVariables.map((k) => (k === from ? to : k));
}
if (asset.props[from] !== undefined) {
asset.props[to] = asset.props[from]!;
delete asset.props[from];
}
for (const { bag } of overrideSites(project, (id) => id === this.assetId)) {
if (bag[from] === undefined) continue;
bag[to] = bag[from]!;
delete bag[from];
}
renameVariablePorts(project, this.assetId, from, to);
}
}
/** Only the order changes: values and the ports naming a variable go by its name (#310). */
export class MoveVariableCommand extends MoveCommand {
constructor(
private readonly assetId: string,
from: number,
to: number,
) {
super('Reorder variables', from, to);
}
protected move(project: Project, from: number, to: number): void {
if (!moveItem(requireVariableOwner(project, this.assetId).variables, from, to))
throw new CommandError(`There is no variable ${from + 1} to move to place ${to + 1}`);
}
}
/** The behavior, once it is known to declare `key`. */
function requireBehaviorVariable(project: Project, assetId: string, key: string): BehaviorAsset {
const asset = requireBehavior(project, assetId);
if (!asset.variables.some((v) => v.key === key)) throw new CommandError(`Variable "${key}" not found`);
return asset;
}
/** Whether a behavior's variable carries on from one `Run behavior` call to the next (#229). */
export class SetVariableKeptCommand implements Command {
readonly label: string;
private previous = false;
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly kept: boolean,
) {
this.label = kept ? `Keep ${key} between calls` : `Reset ${key} every call`;
}
execute(project: Project): void {
const asset = requireBehaviorVariable(project, this.assetId, this.key);
this.previous = isKeptVariable(asset, this.key);
setKeptVariable(asset, this.key, this.kept);
}
undo(project: Project): void {
setKeptVariable(requireBehavior(project, this.assetId), this.key, this.previous);
}
}
/**
* Whether a behavior's variable is seen outside its own graph (#257). Going private drops the
* overrides of every placed object: nobody would see them any more, yet they would still act.
*/
export class SetVariablePrivateCommand implements Command {
readonly label: string;
private previous = false;
private overrides: TakenOverride[] = [];
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly hidden: boolean,
) {
this.label = hidden ? `Make ${key} private` : `Make ${key} public`;
}
execute(project: Project): void {
const asset = requireBehaviorVariable(project, this.assetId, this.key);
this.previous = isPrivateVariable(asset, this.key);
setPrivateVariable(asset, this.key, this.hidden);
this.overrides = this.hidden ? takeOverrides(project, this.assetId, this.key) : [];
}
undo(project: Project): void {
setPrivateVariable(requireBehavior(project, this.assetId), this.key, this.previous);
restoreOverrides(project, this.assetId, this.key, this.overrides);
}
}
/**
* A struct's fields are no properties of anything placed, so the reserved property names are free
* there – as they are for a struct field.
*/
function isValidName(asset: VariableOwnerAsset, name: string): boolean {
return asset.type === 'struct' ? isValidFieldName(name) : isValidVariableName(name);
}
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
/**
* Where declared fields live: on a struct variable, or on a struct field of one, however deep
* (#239). A list item carries its own shape instead.
*/
interface FieldOwner {
fields(): readonly StructFieldDef[];
setFields(next: readonly StructFieldDef[]): void;
}
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
}
/** `path` names the struct fields from the variable down to the struct whose fields are meant. */
function requireFieldOwner(
project: Project,
assetId: string,
key: string,
path: readonly string[],
): FieldOwner {
const def = variableOf(project, assetId, key);
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
if (fieldsAt(def.fields, path) === undefined)
throw new CommandError(`Field "${path.join('.')}" has no fields`);
return {
fields: () => fieldsAt(def.fields, path) ?? [],
setFields: (next) => (def.fields = withFieldsAt(def.fields, path, next)),
};
}
function fieldsAt(
fields: readonly StructFieldDef[],
path: readonly string[],
): readonly StructFieldDef[] | undefined {
if (path.length === 0) return fields;
const field = fields.find((f) => f.key === path[0]);
return field?.kind === 'struct' ? fieldsAt(field.fields, path.slice(1)) : undefined;
}
/** The declaration with the fields at `path` replaced, rebuilt on the way down. */
function withFieldsAt(
fields: readonly StructFieldDef[],
path: readonly string[],
next: readonly StructFieldDef[],
): readonly StructFieldDef[] {
if (path.length === 0) return next;
return fields.map((f) =>
f.key === path[0] && f.kind === 'struct'
? { ...f, fields: withFieldsAt(f.fields, path.slice(1), next) }
: f,
);
}
/**
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
* With a `path` the site is the struct that deep inside the value.
*/
interface StructSite {
id: string;
read(): StructValue;
write(value: StructValue): void;
}
function structSites(project: Project, assetId: string, key: string, path: readonly string[]): StructSite[] {
const structIn = (value: PropertyValue | undefined): StructValue => {
const at = valueAt(value, path);
return isStruct(at) ? at : {};
};
return (
valueHolders(project, assetId, key)
// An override the object does not have stays absent: writing one here would invent a deviation.
.filter((h) => !h.optional || isStruct(h.read()))
.map((holder) => ({
id: holder.id,
read: () => structIn(holder.read()),
write: (value: StructValue) => holder.write(withValueAt(holder.read(), path, value) ?? value),
}))
);
}
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
interface ValueHolder {
id: string;
/** An override may simply not be there; the declared default and the behavior's own always are. */
optional: boolean;
read(): PropertyValue | undefined;
write(value: PropertyValue): void;
}
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
const asset = requireVariableOwner(project, assetId);
const def = asset.variables.find((v) => v.key === key);
const holders: ValueHolder[] = [];
if (def)
holders.push({
id: 'declared',
optional: false,
read: () => def.default,
write: (value) => {
def.default = value;
},
});
holders.push(bagHolder('asset', false, asset.props, key));
for (const site of overrideSites(project, (id) => id === assetId))
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
return holders;
}
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
}
export class AddStructFieldCommand implements Command {
readonly label: string;
/** `path` names the struct the field is added to: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly field: StructFieldDef,
private readonly path: readonly string[] = [],
) {
this.label = `Add field ${field.key}`;
}
execute(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
if (owner.fields().some((f) => f.key === this.field.key))
throw new CommandError(`Field "${this.field.key}" already exists`);
owner.setFields([...owner.fields(), { ...this.field }]);
for (const site of structSites(project, this.assetId, this.key, this.path))
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
}
undo(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
for (const site of structSites(project, this.assetId, this.key, this.path))
site.write(withoutField(site.read(), this.field.key));
}
}
/** Takes the field's value out of every struct that carries it, and gives each one back on undo. */
export class RemoveStructFieldCommand implements Command {
readonly label: string;
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
/** `path` names the struct the field is taken from: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly field: string,
private readonly path: readonly string[] = [],
) {
this.label = `Remove field ${field}`;
}
execute(project: Project): void {
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
const fields = [...owner.fields()];
const field = removeFirst(fields, (f) => f.key === this.field);
if (!field) throw new CommandError(`Field "${this.field}" not found`);
const values = new Map<string, FieldValue>();
for (const site of structSites(project, this.assetId, this.key, this.path)) {
const value = site.read();
const item = value[this.field];
if (item !== undefined) values.set(site.id, item);
site.write(withoutField(value, this.field));
}
owner.setFields(fields);
this.removed = { field, values };
}
undo(project: Project): void {
if (!this.removed) return;
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
const fields = [...owner.fields()];
restoreAt(fields, [this.removed.field]);
owner.setFields(fields);
for (const site of structSites(project, this.assetId, this.key, this.path)) {
const value = this.removed.values.get(site.id);
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
}
}
}
export class RenameStructFieldCommand extends RenameCommand {
/** `path` names the struct the field belongs to: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
from: string,
to: string,
private readonly path: readonly string[] = [],
) {
super(`Rename field ${from}`, from, to);
}
protected rename(project: Project, from: string, to: string): void {
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
const fields = owner.fields();
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
for (const site of structSites(project, this.assetId, this.key, this.path))
site.write(withFieldRenamed(site.read(), from, to));
}
}
/** A struct value is read by field name, so only the declaration changes order (#310). */
export class MoveStructFieldCommand extends MoveCommand {
/** `path` names the struct whose fields are reordered: none for the variable itself. */
constructor(
private readonly assetId: string,
private readonly key: string,
from: number,
to: number,
private readonly path: readonly string[] = [],
) {
super('Reorder fields', from, to);
}
protected move(project: Project, from: number, to: number): void {
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
const fields = [...owner.fields()];
if (!moveItem(fields, from, to))
throw new CommandError(`There is no field ${from + 1} to move to place ${to + 1}`);
owner.setFields(fields);
}
}