@manny-est/node-red-flowpilot 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,21 @@
28
28
  // throwing) AND on disk (each string serializes with id
29
29
  // undefined -> null, confirmed via a live flows.json). Group
30
30
  // membership must only ever change through applyGroupChanges().
31
- nodes: 1
31
+ nodes: 1,
32
+ // Node appearance/documentation fields and node-type-specific
33
+ // internal defaults that models hallucinate sentinel placeholders
34
+ // for during build-loop reviews. None of these are meaningfully
35
+ // settable via a Modify patch — they're either Appearance-tab
36
+ // metadata or type-internal runtime flags. Adding them here
37
+ // prevents false "redaction" blocks with no actionable remediation.
38
+ // Appearance/docs (all node types):
39
+ info: 1, inputLabels: 1, outputLabels: 1, icon: 1,
40
+ // debug node internal display flags:
41
+ console: 1, tostatus: 1, targetType: 1, statusVal: 1, statusType: 1,
42
+ // function node internal flags:
43
+ noerr: 1, initialize: 1, finalize: 1,
44
+ // mqtt in/out retain handling:
45
+ rh: 1
32
46
  };
33
47
 
34
48
  // Sentinel strings written by sanitizeNode (and redactDebugValue) for
@@ -113,7 +127,7 @@
113
127
  // internally for t:"add" events) and pushes one compound history entry so
114
128
  // a single Ctrl+Z removes both the new nodes and their wires together.
115
129
  function applyInsertions(newNodes, newWires, contextNodeIds) {
116
- if (!newNodes || !newNodes.length) { return; }
130
+ if ((!newNodes || !newNodes.length) && (!newWires || !newWires.length)) { return; }
117
131
 
118
132
  // Determine z (flow-tab id) from the active workspace.
119
133
  var z = "";
@@ -246,6 +260,57 @@
246
260
  });
247
261
  });
248
262
 
263
+ // Collision-avoidance stagger: independent components that happen to
264
+ // share the same anchor (e.g. several standalone new nodes each
265
+ // wired from/to the SAME existing node — "add 5 debug taps after
266
+ // this http request node") compute identical target coordinates
267
+ // above and land exactly on top of each other. Detect a component
268
+ // whose origin collides with an earlier one and nudge the WHOLE
269
+ // component (preserving its own internal layout) into a small grid
270
+ // offset from the first occupant, so multi-node insertions never
271
+ // pile invisibly at one point.
272
+ var occupiedOrigins = [];
273
+ function collidesWithOccupied(x, y) {
274
+ return occupiedOrigins.some(function (p) {
275
+ return Math.abs(p.x - x) < 20 && Math.abs(p.y - y) < 20;
276
+ });
277
+ }
278
+ Object.keys(componentAnchors).forEach(function (c) {
279
+ var members = laid.filter(function (n) { return componentOf[n.id] === c; });
280
+ if (!members.length) { return; }
281
+ var baseX = members.reduce(function (m, n) { return Math.min(m, n.x); }, members[0].x);
282
+ var baseY = members.reduce(function (m, n) { return Math.min(m, n.y); }, members[0].y);
283
+ // Walk a single-column grid outward from the natural origin
284
+ // until we find a slot nothing else already occupies — one
285
+ // node per row, never two at the same y (CLAUDE-012: Manny's
286
+ // original spec, which the earlier 2-wide grid still violated
287
+ // by putting a pair at the same vertical level). After 5 nodes
288
+ // in a column, wrap to a new column further right than the
289
+ // first node in the previous one, so wires stay visually
290
+ // distinguishable instead of stacking on top of each other.
291
+ var slot = 0;
292
+ var x = baseX, y = baseY;
293
+ while (collidesWithOccupied(x, y) && slot < 200) {
294
+ slot++;
295
+ x = baseX + Math.floor(slot / 5) * 250;
296
+ y = baseY + (slot % 5) * 90;
297
+ }
298
+ if (slot > 0) {
299
+ var dx = x - baseX, dy = y - baseY;
300
+ members.forEach(function (n) { n.x += dx; n.y += dy; });
301
+ }
302
+ occupiedOrigins.push({ x: x, y: y });
303
+ });
304
+
305
+ // B3: snapshot placeholder→type before ids are overwritten, so wire
306
+ // failure messages can name the placeholder id and type rather than the
307
+ // opaque real hex id the node was never given (node not inserted = id
308
+ // exists in idMap but never landed in the live graph).
309
+ var placeholderToType = {};
310
+ laid.forEach(function (n) {
311
+ if (placeholderIds[n.id]) { placeholderToType[n.id] = n.type || "?"; }
312
+ });
313
+
249
314
  // Build placeholder-id → real-id map and assign real ids + z.
250
315
  var idMap = {};
251
316
  laid.forEach(function (n) {
@@ -338,6 +403,15 @@
338
403
  ? typeDef.outputs
339
404
  : (Array.isArray(n.wires) ? n.wires.length : 0);
340
405
  }
406
+ // The model's "wires" array can disagree with the node type's
407
+ // real port count (e.g. a hallucinated single empty port on a
408
+ // 0-output type like http response) — the editor draws one
409
+ // anchor per n.wires entry regardless of n.outputs, so a
410
+ // mismatch renders a phantom port. Reconcile the array length
411
+ // to the authoritative n.outputs count now that it's resolved.
412
+ if (!Array.isArray(n.wires)) { n.wires = []; }
413
+ while (n.wires.length < n.outputs) { n.wires.push([]); }
414
+ if (n.wires.length > n.outputs) { n.wires.length = n.outputs; }
341
415
  // Apply type-definition defaults for any property the model omitted.
342
416
  // This covers required fields (e.g. statusVal/statusType on debug)
343
417
  // that oneditsave would normally set, preventing a spurious triangle.
@@ -396,7 +470,16 @@
396
470
  var fromNode = findLiveNode(fromId);
397
471
  var toNode = findLiveNode(toId);
398
472
  if (!fromNode || !toNode) {
399
- addMessage("error", "Cannot wire — node not found: " + (!fromNode ? fromId : toId));
473
+ var _failRef = !fromNode ? wire.from : wire.to;
474
+ var _failMsg;
475
+ if (placeholderIds[_failRef]) {
476
+ _failMsg = "Cannot wire — referenced node was not inserted (" +
477
+ _failRef + " / type: " + (placeholderToType[_failRef] || "?") + ")";
478
+ } else {
479
+ _failMsg = "Cannot wire — node not found in editor: " +
480
+ (!fromNode ? fromId : toId);
481
+ }
482
+ addMessage("error", _failMsg);
400
483
  return;
401
484
  }
402
485
  var fromPort = wire.fromPort || 0;
@@ -524,12 +607,13 @@
524
607
  var propertyChanges = [];
525
608
  var wiresChanged = false;
526
609
  var redactionSkips = 0;
610
+ var redactedKeys = [];
527
611
  Object.keys(modNode).forEach(function (k) {
528
612
  if (DIFF_SKIP[k]) { return; }
529
613
  var newRaw = modNode[k];
530
614
  // If the model echoed a sanitizer sentinel, the field is opaque —
531
615
  // we can't meaningfully compare or apply it, so skip entirely.
532
- if (isSanitizeSentinel(newRaw)) { redactionSkips++; return; }
616
+ if (isSanitizeSentinel(newRaw)) { redactionSkips++; redactedKeys.push(k); return; }
533
617
  var oldRaw = liveNode ? liveNode[k] : undefined;
534
618
  var oldStr, newStr;
535
619
  try { oldStr = JSON.stringify(oldRaw); } catch (e) { oldStr = String(oldRaw); }
@@ -538,7 +622,7 @@
538
622
  if (k === "wires") { wiresChanged = true; return; }
539
623
  propertyChanges.push({ key: k, oldVal: oldRaw, newVal: newRaw });
540
624
  });
541
- return { propertyChanges: propertyChanges, wiresChanged: wiresChanged, redactionSkips: redactionSkips };
625
+ return { propertyChanges: propertyChanges, wiresChanged: wiresChanged, redactionSkips: redactionSkips, redactedKeys: redactedKeys };
542
626
  }
543
627
 
544
628
  // Diff outgoing wires for an existing node: compare what's live in the graph
@@ -642,6 +726,7 @@
642
726
  propertyChanges: diff.propertyChanges,
643
727
  wiresChanged: diff.wiresChanged,
644
728
  redactionSkips: diff.redactionSkips,
729
+ redactedKeys: diff.redactedKeys,
645
730
  wiresDiff: wiresDiff,
646
731
  name: modNode.name || (liveNode && liveNode.name) || "",
647
732
  type: modNode.type || (liveNode && liveNode.type) || ""
@@ -662,7 +747,7 @@
662
747
  var hasNewNodes = newNodes.length > 0;
663
748
  var hasRemoveNodes = removeNodes.length > 0;
664
749
  var hasNewGroups = newGroups.length > 0;
665
- var hasAnyChanges = hasPropChanges || hasWireChanges || hasNewNodes || hasRemoveNodes || hasNewGroups;
750
+ var hasAnyChanges = hasPropChanges || hasWireChanges || hasNewNodes || hasRemoveNodes || hasNewGroups || (newWires && newWires.length > 0);
666
751
  var totalRedactionSkips = nodeDiffs.reduce(function (s, d) { return s + (d.redactionSkips || 0); }, 0);
667
752
 
668
753
  var $msg = $("<div>").addClass("fp-message fp-review");
@@ -715,15 +800,27 @@
715
800
  });
716
801
  if (redactionOnlyDiffs.length > 0) {
717
802
  var redSectionTop = hasPropChanges ? "12px" : "0";
718
- $("<div>").addClass("fp-review-count").css("margin-top", redSectionTop)
719
- .text("Not applied targets redacted value(s):")
803
+ var totalRedFields = redactionOnlyDiffs.reduce(function (s, d) {
804
+ return s + (Array.isArray(d.redactedKeys) && d.redactedKeys.length ? d.redactedKeys.length : 1);
805
+ }, 0);
806
+ var $redToggle = $("<div>").addClass("fp-review-count")
807
+ .css({ "margin-top": redSectionTop, "cursor": "pointer" })
808
+ .text("▶ Not applied — FlowPilot can’t change these directly (" + totalRedFields + " field(s))")
720
809
  .appendTo($summaryPanel);
810
+ var $redContent = $("<div>").addClass("fp-hidden").appendTo($summaryPanel);
811
+ $redToggle.on("click", function () {
812
+ var collapsed = $redContent.hasClass("fp-hidden");
813
+ $redContent.toggleClass("fp-hidden", !collapsed);
814
+ $redToggle.text((collapsed ? "▼" : "▶") + " Not applied — FlowPilot can’t change these directly (" + totalRedFields + " field(s))");
815
+ });
721
816
  redactionOnlyDiffs.forEach(function (d) {
722
- var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
817
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($redContent);
723
818
  var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
724
819
  $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
820
+ var keys = Array.isArray(d.redactedKeys) && d.redactedKeys.length ? d.redactedKeys : [];
821
+ var fieldList = keys.length ? keys.join(", ") : "one or more fields";
725
822
  $("<div>").addClass("fp-diff-warn")
726
- .text("Proposed change targets a redacted fieldedit the value directly in the node editor.")
823
+ .text("Cannot change “" + fieldList + "” FlowPilot cannot read the live value for this field (internal node config). Edit it directly in the node editor if needed.")
727
824
  .appendTo($section);
728
825
  });
729
826
  }
@@ -845,7 +942,7 @@
845
942
  if (!hasAnyChanges) {
846
943
  if (totalRedactionSkips > 0) {
847
944
  $("<div>").addClass("fp-warning")
848
- .text("All proposed changes target redacted fieldsFlowPilot can only see those values as placeholders. Edit the field(s) directly in the node editor, then retry for any remaining changes.")
945
+ .text("None of the proposed changes could be appliedall targeted fields are internal node config that FlowPilot cannot read or modify directly. See the field names above and edit them in the node editor if needed.")
849
946
  .appendTo($actions);
850
947
  } else {
851
948
  $("<div>").addClass("fp-review-hint")
@@ -863,17 +960,11 @@
863
960
  : hasNewNodes ? "Insert Nodes"
864
961
  : "Apply Changes"; // covers a request that ONLY creates/updates a group
865
962
 
866
- // Pop-out: tag this panel with everything applyInsertions/
867
- // applyModifications/applyGroupChanges need to re-run from a
868
- // relayed click nodeDiffs re-serialized without liveNode (a
869
- // live RED node object, not JSON-safe; applyModifications
870
- // re-fetches it itself via findLiveNode anyway, so nothing is
871
- // lost). A plain Modify call (applyCallback is the bare
872
- // applyModifications reference) gets "data-fp-apply-modify"; a
873
- // /build loop fix (buildFixInfo set — see applyBuildLoopFix)
874
- // gets "data-fp-apply-build-fix" instead, carrying capReached
875
- // too since the relayed click needs to run the SAME loop
876
- // bookkeeping a local click would, not just applyModifications.
963
+ // sharedApplyData is stored in the review record (Phase 10 0B)
964
+ // so the pop-out can relay an applyByRecordId intent to the
965
+ // parent without serializing payload into DOM attributes.
966
+ // nodeDiffs is re-serialized without liveNode (a RED node object,
967
+ // not JSON-safe; applyModifications re-fetches via findLiveNode).
877
968
  var sharedApplyData = {
878
969
  nodeDiffs: nodeDiffs.map(function (d) {
879
970
  return {
@@ -892,40 +983,57 @@
892
983
  existingNodeIds: nodes.map(function (n) { return n.id; }),
893
984
  hasMutations: hasMutations
894
985
  };
895
- if (applyCallback === applyModifications) {
896
- $msg.attr("data-fp-apply-modify", JSON.stringify(sharedApplyData));
897
- } else if (buildFixInfo) {
898
- sharedApplyData.capReached = !!buildFixInfo.capReached;
899
- $msg.attr("data-fp-apply-build-fix", JSON.stringify(sharedApplyData));
900
- }
986
+ if (buildFixInfo) { sharedApplyData.capReached = !!buildFixInfo.capReached; }
987
+ var _modRecord = addRecord("review", {
988
+ subkind: buildFixInfo ? "build-fix" : "modify",
989
+ sharedApplyData: sharedApplyData,
990
+ state: "pending"
991
+ });
992
+ $msg.attr("data-fp-record-id", _modRecord.id);
901
993
 
994
+ var hintParts = [];
995
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
996
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
997
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
998
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
999
+ if (!hasNewNodes && newWires && newWires.length > 0) { hintParts.push("connections added"); }
1000
+ var applySub = hintParts.length ? "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo." : "";
902
1001
  var $applyBtn = $("<button>")
903
- .addClass("red-ui-button red-ui-button-primary")
1002
+ .addClass("fp-chip fp-chip-card")
904
1003
  .attr("type", "button")
905
- .text(btnLabel)
906
1004
  .on("click", function () {
907
- $applyBtn.prop("disabled", true).text("Applying…");
1005
+ $applyBtn.prop("disabled", true);
1006
+ $applyTitle.text("Applying…");
908
1007
  // Insertions run FIRST so their placeholder→real-id map is
909
1008
  // available to applyModifications/applyGroupChanges — an
910
1009
  // existing node's rewired "wires" (Tier 3) or a new
911
1010
  // group's membership may point at a node being inserted
912
1011
  // in this same response.
913
1012
  var idMap = {};
914
- if (hasNewNodes) {
1013
+ var hasNewWires = newWires && newWires.length > 0;
1014
+ if (hasNewNodes || hasNewWires) {
915
1015
  idMap = applyInsertions(newNodes, newWires, nodes.map(function (n) { return n.id; })) || {};
916
1016
  }
917
- if (hasMutations && applyCallback) { applyCallback(nodeDiffs, removeNodes, null, idMap); }
1017
+ // W4 Phase 2: applyCallback also runs the verify read-back,
1018
+ // so it must fire for insertion-only Modifies too (no
1019
+ // property/wire change on an EXISTING node), not just when
1020
+ // hasMutations is true — otherwise "exists"/"wire" checks on
1021
+ // newly inserted nodes would never run.
1022
+ if ((hasMutations || hasNewNodes || hasNewWires) && applyCallback) {
1023
+ applyCallback(nodeDiffs, removeNodes, null, idMap);
1024
+ }
918
1025
  if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
919
- $applyBtn.text("Done ");
1026
+ if (_modRecord) { _modRecord.state = "applied"; }
1027
+ $applyTitle.text("Done ✓");
920
1028
  });
1029
+ $("<span>").addClass("fp-chip-icon")
1030
+ .append($("<i>").addClass(buildFixInfo ? "fa fa-wrench" : "fa fa-check-circle"))
1031
+ .appendTo($applyBtn);
1032
+ var $applyBody = $("<span>").addClass("fp-chip-body").appendTo($applyBtn);
1033
+ var $applyTitle = $("<span>").addClass("fp-chip-title").text(buildFixInfo ? "Apply Fix" : btnLabel).appendTo($applyBody);
1034
+ $("<span>").addClass("fp-chip-sub").text(buildFixInfo && buildFixInfo.capReached ? "Apply final fix and stop the loop" : applySub).appendTo($applyBody);
1035
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($applyBtn);
921
1036
  $actions.append($applyBtn);
922
- var hintParts = [];
923
- if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
924
- if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
925
- if (hasNewGroups) { hintParts.push("groups are created/updated"); }
926
- if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
927
- var hintText = "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.";
928
- $("<span>").addClass("fp-review-hint").text(hintText).appendTo($actions);
929
1037
  }
930
1038
 
931
1039
  $box.append($msg);
@@ -953,7 +1061,17 @@
953
1061
 
954
1062
  var oldValues = {};
955
1063
  d.propertyChanges.forEach(function (c) { oldValues[c.key] = liveNode[c.key]; });
956
- d.propertyChanges.forEach(function (c) { liveNode[c.key] = c.newVal; });
1064
+ d.propertyChanges.forEach(function (c) {
1065
+ // Rewrite placeholder ids that point at newly-inserted config
1066
+ // nodes (e.g. an MQTT out node's broker: "fp-new-0" referring
1067
+ // to a new mqtt-broker in the same response's newNodes).
1068
+ // applyInsertions returns idMap and it flows to us via the
1069
+ // applyCallback chain — idMap[placeholder] is the real id.
1070
+ var val = (idMap && typeof c.newVal === "string" && idMap[c.newVal])
1071
+ ? idMap[c.newVal]
1072
+ : c.newVal;
1073
+ liveNode[c.key] = val;
1074
+ });
957
1075
 
958
1076
  // Switch nodes derive their port count from rules.length (the edit
959
1077
  // dialog's "outputCount" map assigns each rule its own output and
@@ -1567,18 +1685,10 @@
1567
1685
  var v = validateGeneratedFlow(nodes);
1568
1686
 
1569
1687
  var $msg = $("<div>").addClass("fp-message fp-review");
1570
- // Pop-out slice 3 (plain Generate/Document, no onImported): tag
1571
- // with the raw flow data so the relay can wire up a WORKING "Add
1572
- // to workspace" button in the pop-out. /build's first proposal
1573
- // (onImported set, buildGoal present) gets its own tag instead —
1574
- // importing it also needs to start the loop (startBuildLoop),
1575
- // which the parent does itself once it gets the relayed intent;
1576
- // see the "applyBuild" handler in initMainWindow.
1577
- if (!onImported) {
1578
- $msg.attr("data-fp-apply-flow", JSON.stringify(nodes));
1579
- } else if (buildGoal) {
1580
- $msg.attr("data-fp-apply-build", JSON.stringify({ flow: nodes, goal: buildGoal }));
1581
- }
1688
+ // The review record (created in the action row when nodes are valid)
1689
+ // carries data-fp-record-id; the pop-out uses it to relay
1690
+ // applyByRecordId to the parent see bindReviewApplyButtons/
1691
+ // applyByRecordId in initMainWindow (Phase 10 0B).
1582
1692
  $("<div>").addClass("fp-label").text("GENERATED FLOW — REVIEW").appendTo($msg);
1583
1693
 
1584
1694
  var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
@@ -1656,22 +1766,342 @@
1656
1766
  } else if (!nodes.length) {
1657
1767
  $("<div>").addClass("fp-warning").text("No nodes were generated — nothing to add.").appendTo($actions);
1658
1768
  } else {
1659
- var $addBtn = $("<button>")
1660
- .addClass("red-ui-button red-ui-button-primary")
1769
+ var _genRecord = addRecord("review", {
1770
+ subkind: onImported ? "build-generate" : "generate",
1771
+ flow: nodes,
1772
+ buildGoal: buildGoal || null,
1773
+ onImported: onImported || null,
1774
+ state: "pending"
1775
+ });
1776
+ $msg.attr("data-fp-record-id", _genRecord.id);
1777
+ if (onImported && buildGoal) {
1778
+ // B1: primary deploy-and-verify chip (accent, full-width) + secondary
1779
+ // plain escape hatch. Only when buildGoal is set — that signals this
1780
+ // is a build-loop context, not just a plain post-import callback.
1781
+ var $primaryChip = $("<button>")
1782
+ .addClass("fp-chip fp-chip-card")
1783
+ .attr("type", "button")
1784
+ .on("click", function () {
1785
+ $primaryChip.prop("disabled", true);
1786
+ $secondaryAddBtn.prop("disabled", true);
1787
+ if (_genRecord) { _genRecord.state = "applied"; }
1788
+ importGeneratedFlow(nodes, onImported);
1789
+ });
1790
+ $("<span>").addClass("fp-chip-icon")
1791
+ .append($("<i>").addClass("fa fa-check-circle"))
1792
+ .appendTo($primaryChip);
1793
+ var $chipBody = $("<span>").addClass("fp-chip-body").appendTo($primaryChip);
1794
+ $("<span>").addClass("fp-chip-title").text("Deploy & verify this works →").appendTo($chipBody);
1795
+ $("<span>").addClass("fp-chip-sub").text("Add to canvas, deploy, then run the verification loop").appendTo($chipBody);
1796
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($primaryChip);
1797
+ $actions.append($primaryChip);
1798
+ var $secondaryAddBtn = $("<button>")
1799
+ .addClass("fp-chip fp-chip-card fp-chip-card-alt")
1800
+ .attr("type", "button")
1801
+ .on("click", function () {
1802
+ $secondaryAddBtn.prop("disabled", true);
1803
+ $primaryChip.prop("disabled", true);
1804
+ if (_genRecord) { _genRecord.state = "applied"; }
1805
+ importGeneratedFlow(nodes, null);
1806
+ });
1807
+ var $altBody = $("<span>").addClass("fp-chip-body").appendTo($secondaryAddBtn);
1808
+ $("<span>").addClass("fp-chip-title").text("Just add to canvas").appendTo($altBody);
1809
+ $("<span>").addClass("fp-chip-sub").text("Skips deploy & verification").appendTo($altBody);
1810
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($secondaryAddBtn);
1811
+ $actions.append($secondaryAddBtn);
1812
+ } else if (onImported) {
1813
+ // Has a post-import callback (e.g. verifyImportedNodes) but no build
1814
+ // loop — chip button that still fires the callback on import.
1815
+ var $addBtn = $("<button>")
1816
+ .addClass("fp-chip fp-chip-card")
1817
+ .attr("type", "button")
1818
+ .on("click", function () {
1819
+ $addBtn.prop("disabled", true);
1820
+ if (_genRecord) { _genRecord.state = "applied"; }
1821
+ importGeneratedFlow(nodes, onImported);
1822
+ });
1823
+ var $addBody = $("<span>").addClass("fp-chip-body").appendTo($addBtn);
1824
+ $("<span>").addClass("fp-chip-title").text("Add to canvas").appendTo($addBody);
1825
+ $("<span>").addClass("fp-chip-sub").text("Click the canvas to place the nodes").appendTo($addBody);
1826
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($addBtn);
1827
+ $actions.append($addBtn);
1828
+ } else {
1829
+ var $addBtn = $("<button>")
1830
+ .addClass("fp-chip fp-chip-card")
1831
+ .attr("type", "button")
1832
+ .on("click", function () {
1833
+ $addBtn.prop("disabled", true);
1834
+ if (_genRecord) { _genRecord.state = "applied"; }
1835
+ importGeneratedFlow(nodes, null);
1836
+ });
1837
+ var $addBody = $("<span>").addClass("fp-chip-body").appendTo($addBtn);
1838
+ $("<span>").addClass("fp-chip-title").text("Add to canvas").appendTo($addBody);
1839
+ $("<span>").addClass("fp-chip-sub").text("Click the canvas to place the nodes").appendTo($addBody);
1840
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($addBtn);
1841
+ $actions.append($addBtn);
1842
+ }
1843
+ }
1844
+
1845
+ $box.append($msg);
1846
+ scrollMessagesToBottom();
1847
+ return $msg;
1848
+ }
1849
+
1850
+ // ---- Refresh-from-history re-render helpers (Phase 10, 0A) -------------
1851
+ // Called by rerenderRecord() in main.js during refreshView(). Reconstruct
1852
+ // review panels from stored record data — the sharedApplyData already
1853
+ // holds everything needed without re-querying the live editor.
1854
+
1855
+ function rerenderReviewRecord(rec) {
1856
+ if (!rec) { return; }
1857
+ switch (rec.subkind) {
1858
+ case "modify":
1859
+ case "build-fix":
1860
+ rerenderModifyReview(rec);
1861
+ break;
1862
+ case "generate":
1863
+ case "build-generate":
1864
+ rerenderGeneratedReview(rec);
1865
+ break;
1866
+ }
1867
+ }
1868
+
1869
+ function rerenderModifyReview(rec) {
1870
+ var $box = el("#fp-messages");
1871
+ if (!$box.length) { return; }
1872
+
1873
+ var d = rec.sharedApplyData || {};
1874
+ var nodeDiffs = Array.isArray(d.nodeDiffs) ? d.nodeDiffs : [];
1875
+ var removeNodes = Array.isArray(d.removeNodes) ? d.removeNodes : [];
1876
+ var newNodes = Array.isArray(d.newNodes) ? d.newNodes : [];
1877
+ var newWires = Array.isArray(d.newWires) ? d.newWires : [];
1878
+ var newGroups = Array.isArray(d.newGroups) ? d.newGroups : [];
1879
+ var existingIds = Array.isArray(d.existingNodeIds) ? d.existingNodeIds : [];
1880
+ var hasMutations = !!d.hasMutations;
1881
+ var capReached = !!d.capReached;
1882
+ var isBuildFix = rec.subkind === "build-fix";
1883
+
1884
+ var hasPropChanges = nodeDiffs.some(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0; });
1885
+ var hasWireChanges = nodeDiffs.some(function (nd) { return nd.wiresDiff && (nd.wiresDiff.toRemove.length > 0 || nd.wiresDiff.toAdd.length > 0); });
1886
+ var hasNewNodes = newNodes.length > 0;
1887
+ var hasRemoveNodes = removeNodes.length > 0;
1888
+ var hasNewGroups = newGroups.length > 0;
1889
+ var nodesWithChanges = nodeDiffs.filter(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0; }).length;
1890
+ var missingLive = nodeDiffs.some(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0 && !findLiveNode(nd.modNode && nd.modNode.id); });
1891
+
1892
+ // Add the record first so the Apply button's closure can reference it.
1893
+ var _newRec = addRecord("review", {
1894
+ subkind: rec.subkind,
1895
+ sharedApplyData: rec.sharedApplyData,
1896
+ state: rec.state
1897
+ });
1898
+
1899
+ var $msg = $("<div>").addClass("fp-message fp-review");
1900
+ $("<div>").addClass("fp-label")
1901
+ .text(isBuildFix ? "BUILD LOOP — FIX REVIEW" : "MODIFY FLOW — REVIEW CHANGES")
1902
+ .appendTo($msg);
1903
+
1904
+ $msg.attr("data-fp-record-id", _newRec.id);
1905
+
1906
+ var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
1907
+ var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
1908
+ $("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
1909
+
1910
+ var $summaryPanel = $("<div>").addClass("fp-tab-panel");
1911
+ var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
1912
+ $msg.append($summaryPanel, $jsonPanel);
1913
+
1914
+ // Prop diffs
1915
+ if (hasPropChanges) {
1916
+ $("<div>").addClass("fp-review-count")
1917
+ .text(nodesWithChanges + " of " + nodeDiffs.length + " node(s) will change:")
1918
+ .appendTo($summaryPanel);
1919
+ nodeDiffs.forEach(function (nd) {
1920
+ if (!nd.propertyChanges || !nd.propertyChanges.length) { return; }
1921
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
1922
+ var title = nd.type + (nd.name ? " — \"" + nd.name + "\"" : "");
1923
+ $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
1924
+ if (!findLiveNode(nd.modNode && nd.modNode.id)) {
1925
+ $("<div>").addClass("fp-diff-warn")
1926
+ .text("⚠ Node not found in editor (id: " + (nd.modNode && nd.modNode.id) + ")")
1927
+ .appendTo($section);
1928
+ return;
1929
+ }
1930
+ nd.propertyChanges.forEach(function (c) {
1931
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1932
+ $("<span>").addClass("fp-diff-key").text(c.key).appendTo($row);
1933
+ $("<span>").addClass("fp-diff-old").text(formatDiffVal(c.oldVal)).appendTo($row);
1934
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
1935
+ $("<span>").addClass("fp-diff-new").text(formatDiffVal(c.newVal)).appendTo($row);
1936
+ });
1937
+ });
1938
+ }
1939
+ // Wire diffs — same rendering as original addModifyReview
1940
+ if (hasWireChanges) {
1941
+ nodeDiffs.forEach(function (nd) {
1942
+ var wd = nd.wiresDiff;
1943
+ if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
1944
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
1945
+ $("<div>").addClass("fp-diff-node-title")
1946
+ .text(nd.type + (nd.name ? " — \"" + nd.name + "\"" : ""))
1947
+ .appendTo($section);
1948
+ wd.toRemove.forEach(function (entry) {
1949
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
1950
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
1951
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1952
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
1953
+ $("<span>").addClass("fp-diff-old").text("→ " + tgtLabel).appendTo($row);
1954
+ $("<span>").addClass("fp-diff-arrow").text("✕").appendTo($row);
1955
+ $("<span>").addClass("fp-diff-new").text("").appendTo($row);
1956
+ });
1957
+ wd.toAdd.forEach(function (entry) {
1958
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
1959
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
1960
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1961
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
1962
+ $("<span>").addClass("fp-diff-old").text("").appendTo($row);
1963
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
1964
+ $("<span>").addClass("fp-diff-new").text(tgtLabel).appendTo($row);
1965
+ });
1966
+ });
1967
+ }
1968
+ // Removals
1969
+ if (hasRemoveNodes) {
1970
+ $("<div>").addClass("fp-review-count fp-diff-warn")
1971
+ .text(removeNodes.length + " node(s) to remove:")
1972
+ .appendTo($summaryPanel);
1973
+ var $rmList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1974
+ removeNodes.forEach(function (id) {
1975
+ var lv = RED.nodes.node ? RED.nodes.node(id) : null;
1976
+ var label = lv ? ((lv.name || lv.type || id) + " (" + id + ")") : id + " (not found)";
1977
+ $("<li>").addClass("fp-diff-warn").text("✕ " + label).appendTo($rmList);
1978
+ });
1979
+ }
1980
+ // New nodes
1981
+ if (hasNewNodes) {
1982
+ $("<div>").addClass("fp-review-count")
1983
+ .text(newNodes.length + " node(s) to insert:")
1984
+ .appendTo($summaryPanel);
1985
+ var $newList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1986
+ newNodes.forEach(function (n) {
1987
+ $("<li>").text((n.type || "unknown") + (n.name ? " — \"" + n.name + "\"" : "")).appendTo($newList);
1988
+ });
1989
+ if (newWires.length > 0) {
1990
+ $("<div>").addClass("fp-review-count").css("margin-top", "8px")
1991
+ .text(newWires.length + " wire connection(s):")
1992
+ .appendTo($summaryPanel);
1993
+ var $wireList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1994
+ newWires.forEach(function (wire) {
1995
+ var fromLabel = resolveWireRef(wire.from, newNodes);
1996
+ var toLabel = resolveWireRef(wire.to, newNodes);
1997
+ var portNote = (wire.fromPort && wire.fromPort > 0) ? " [port " + wire.fromPort + "]" : "";
1998
+ $("<li>").text(fromLabel + portNote + " → " + toLabel).appendTo($wireList);
1999
+ });
2000
+ }
2001
+ }
2002
+ // Groups
2003
+ if (hasNewGroups) {
2004
+ $("<div>").addClass("fp-review-count")
2005
+ .text(newGroups.length + " group(s) to create/update:")
2006
+ .appendTo($summaryPanel);
2007
+ var $grpList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
2008
+ newGroups.forEach(function (g) {
2009
+ $("<li>").text((g.name ? "\"" + g.name + "\"" : "(unnamed)") +
2010
+ " — " + (Array.isArray(g.nodes) ? g.nodes.length : 0) + " node(s)").appendTo($grpList);
2011
+ });
2012
+ }
2013
+
2014
+ // JSON tab
2015
+ var jsonText = JSON.stringify(d, null, 2);
2016
+ var $copyBtn = $("<button>").addClass("red-ui-button red-ui-button-small").attr("type", "button").text("Copy")
2017
+ .on("click", function () { copyToClipboard($copyBtn, jsonText); });
2018
+ $("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
2019
+ $("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
2020
+
2021
+ $tabSummary.on("click", function () {
2022
+ $tabSummary.addClass("fp-tab-active"); $tabJson.removeClass("fp-tab-active");
2023
+ $summaryPanel.removeClass("fp-hidden"); $jsonPanel.addClass("fp-hidden");
2024
+ });
2025
+ $tabJson.on("click", function () {
2026
+ $tabJson.addClass("fp-tab-active"); $tabSummary.removeClass("fp-tab-active");
2027
+ $jsonPanel.removeClass("fp-hidden"); $summaryPanel.addClass("fp-hidden");
2028
+ });
2029
+
2030
+ // Action row
2031
+ var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
2032
+ if (rec.state === "applied") {
2033
+ $("<button>").addClass("red-ui-button red-ui-button-primary")
2034
+ .attr("type", "button").prop("disabled", true).text("Applied ✓")
2035
+ .appendTo($actions);
2036
+ } else if (missingLive && (hasPropChanges || hasWireChanges)) {
2037
+ $("<div>").addClass("fp-warning")
2038
+ .text("One or more nodes could not be found in the editor. Cannot apply safely.")
2039
+ .appendTo($actions);
2040
+ } else {
2041
+ var btnLabel = (hasMutations && hasNewNodes) ? "Apply & Insert"
2042
+ : hasMutations ? "Apply Changes"
2043
+ : hasNewNodes ? "Insert Nodes"
2044
+ : "Apply Changes";
2045
+ var hintParts = [];
2046
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
2047
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
2048
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
2049
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
2050
+ if (!hasNewNodes && newWires && newWires.length > 0) { hintParts.push("connections added"); }
2051
+ var applySub = hintParts.length ? "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo." : "";
2052
+ var $applyBtn = $("<button>")
2053
+ .addClass("fp-chip fp-chip-card")
1661
2054
  .attr("type", "button")
1662
- .text("Add to workspace")
1663
2055
  .on("click", function () {
1664
- $addBtn.prop("disabled", true).text("Click the canvas to place…");
1665
- importGeneratedFlow(nodes, onImported);
2056
+ $applyBtn.prop("disabled", true);
2057
+ $applyTitle.text("Applying…");
2058
+ var idMap = {};
2059
+ if (hasNewNodes || (newWires && newWires.length > 0)) { idMap = applyInsertions(newNodes, newWires, existingIds) || {}; }
2060
+ if (isBuildFix) {
2061
+ applyBuildLoopFix(nodeDiffs, removeNodes, idMap, capReached);
2062
+ } else {
2063
+ if (hasMutations) { applyModifications(nodeDiffs, removeNodes, null, idMap); }
2064
+ if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
2065
+ }
2066
+ if (_newRec) { _newRec.state = "applied"; }
2067
+ $applyTitle.text("Done ✓");
1666
2068
  });
1667
- $actions.append($addBtn);
1668
- $("<span>").addClass("fp-review-hint")
1669
- .text("Opens Node-RED's normal place-at-cursor import — click the canvas to drop the nodes.")
1670
- .appendTo($actions);
2069
+ $("<span>").addClass("fp-chip-icon")
2070
+ .append($("<i>").addClass(isBuildFix ? "fa fa-wrench" : "fa fa-check-circle"))
2071
+ .appendTo($applyBtn);
2072
+ var $applyBody = $("<span>").addClass("fp-chip-body").appendTo($applyBtn);
2073
+ var $applyTitle = $("<span>").addClass("fp-chip-title").text(isBuildFix ? "Apply Fix" : btnLabel).appendTo($applyBody);
2074
+ $("<span>").addClass("fp-chip-sub").text(isBuildFix && capReached ? "Apply final fix and stop the loop" : applySub).appendTo($applyBody);
2075
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($applyBtn);
2076
+ $actions.append($applyBtn);
1671
2077
  }
1672
2078
 
1673
2079
  $box.append($msg);
1674
2080
  scrollMessagesToBottom();
1675
- return $msg;
2081
+ }
2082
+
2083
+ function rerenderGeneratedReview(rec) {
2084
+ var nodes = Array.isArray(rec.flow) ? rec.flow : [];
2085
+ if (rec.state === "applied") {
2086
+ var $box = el("#fp-messages");
2087
+ if (!$box.length) { return; }
2088
+ var $msg = $("<div>").addClass("fp-message fp-review");
2089
+ $("<div>").addClass("fp-label").text("GENERATED FLOW — APPLIED ✓").appendTo($msg);
2090
+ $("<div>").addClass("fp-review-actions")
2091
+ .append($("<button>").addClass("red-ui-button red-ui-button-primary")
2092
+ .attr("type", "button").prop("disabled", true).text("Applied ✓"))
2093
+ .appendTo($msg);
2094
+ $box.append($msg);
2095
+ addRecord("review", {
2096
+ subkind: rec.subkind, flow: rec.flow,
2097
+ buildGoal: rec.buildGoal, onImported: rec.onImported, state: "applied"
2098
+ });
2099
+ scrollMessagesToBottom();
2100
+ } else {
2101
+ // Re-call addGeneratedReview — it revalidates and creates a fully
2102
+ // interactive panel, including its own addRecord call. onImported is
2103
+ // a live function ref (valid within the same session).
2104
+ addGeneratedReview(nodes, rec.onImported || null, rec.buildGoal || null);
2105
+ }
1676
2106
  }
1677
2107