Live undo/redo: add/delete component and link, move, property edits

Undo and redo now work against a connected device; each step is sent
there through Sox. Deleting a component first reads its whole subtree
back (config, unloaded children, links, place among siblings) so undo
can recreate it; recreated components take new device ids, which the
DOM elements pick up so later steps still reach them. Binary Buf
config (a user's cred) is read with read_prop, since the cache decodes
every Buf as a Str. Moves, tidy layout, and Properties / page editor /
user edits record only what the device accepted.

A step that does not fully reach the device warns and clears the
history. History is also cleared on every view change in live mode,
as opening a sheet rebuilds the DOM from the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:03:26 +03:00
co-authored by Claude Opus 5.5
parent fd22eb314d
commit b98fe828da
9 changed files with 534 additions and 67 deletions
+198 -38
View File
@@ -905,13 +905,15 @@ def reconcile_schema_kits_before_save():
return True
def build_config_values(full_type, overrides=None):
def build_config_values(full_type, overrides=None, device_values=None):
"""Builds SOX config_values list from manifest defaults for an add command.
`overrides` maps slot name to the document-form value to send instead of the default.
`device_values` maps slot name to a value as the device sent it, for putting back a
component exactly as it was; it wins over both.
Raises ValueError when a value does not fit its slot's type.
"""
from live_hooks import _coerce_value, _wire_type
from live_hooks import _coerce_value, _wire_type, wire_value
slots = app_state.final_components_registry.get(full_type, [])
config_values = []
@@ -920,12 +922,15 @@ def build_config_values(full_type, overrides=None):
continue
if slot["type"] == "void":
continue
tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True)
if device_values and slot["name"] in device_values:
config_values.append((tid, wire_value(tid, device_values[slot["name"]])))
continue
val = (overrides or {}).get(slot["name"], slot["default"])
if val is None:
val = properties.type_zero(slot["type"])
if val is None:
continue
tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True)
config_values.append((tid, _coerce_value(tid, val)))
return config_values
@@ -1015,7 +1020,12 @@ def add_component(kit_name, type_name, grid_x, grid_y):
position_failed = not live_hooks.live_set_prop_value(
element, "meta", meta_prop.get("val", "0"))
if not app_state.live_mode:
if app_state.live_mode:
undo.push(LiveComponentCommand(
parent, index, element, present=True,
label=f"add {comp_name}"
))
else:
undo.push(undo.AddComponentCommand(
parent, index, element,
label=f"add {comp_name}"
@@ -1058,11 +1068,7 @@ def remove_link(link_element):
to_ref = link_element.get("to", "")
if app_state.live_mode:
from_comp_path, from_slot_name = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref)
to_comp_path, to_slot_name = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref)
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name):
if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)):
live_hooks.warn_failure("Delete Link",
f"The link {from_ref} → {to_ref} was not removed from the device")
return
@@ -1075,7 +1081,12 @@ def remove_link(link_element):
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
if not app_state.live_mode:
if app_state.live_mode:
undo.push(LiveLinkCommand(
links_elem, index, link_element, present=False,
label=f"delete link {from_slot}→{to_slot}"
))
else:
undo.push(undo.DeleteLinkCommand(
links_elem, index, link_element,
label=f"delete link {from_slot}→{to_slot}"
@@ -1096,11 +1107,7 @@ def add_link(from_path_slot, to_path_slot):
links_elem = ET.SubElement(app_state.xml_root_element, "links")
if app_state.live_mode:
from_comp_path, from_slot_name = from_path_slot.rsplit(".", 1) if "." in from_path_slot else ("", from_path_slot)
to_comp_path, to_slot_name = to_path_slot.rsplit(".", 1) if "." in to_path_slot else ("", to_path_slot)
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name):
if not live_hooks.live_add_link(*link_ends(from_path_slot, to_path_slot)):
live_hooks.warn_failure("Add Link",
f"The link {from_path_slot} → {to_path_slot} was not made on the device")
return
@@ -1111,7 +1118,12 @@ def add_link(from_path_slot, to_path_slot):
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
if not app_state.live_mode:
if app_state.live_mode:
undo.push(LiveLinkCommand(
links_elem, list(links_elem).index(link_elem), link_elem, present=True,
label=f"add link {from_slot}→{to_slot}"
))
else:
undo.push(undo.AddLinkCommand(
links_elem, link_elem,
label=f"add link {from_slot}→{to_slot}"
@@ -1137,37 +1149,21 @@ def execute_cascading_deletion(comp_element):
if not confirm:
return False
snapshot = None
if app_state.live_mode:
# Read before it goes: once deleted, the device has nothing left to read back.
snapshot = live_hooks.read_subtree(comp_element)
unreadable = live_hooks.last_error if snapshot is None else ""
if not live_hooks.live_delete_component(comp_element):
live_hooks.warn_failure("Delete Component", f"{comp_name} was not deleted from the device")
return False
comp_path = get_component_path(comp_element, app_state.xml_root_element)
all_elements_to_prune = [comp_element] + descendant_comps
for elem in all_elements_to_prune:
if elem in app_state.active_components_list:
app_state.active_components_list.remove(elem)
# Positions are recorded before anything is removed, so the delete can be reversed.
link_records = []
if app_state.xml_root_element is not None:
links_elem = app_state.xml_root_element.find("links")
if links_elem is not None:
for index, link in enumerate(list(links_elem)):
if link.tag != "link":
continue
l_from = link.get("from", "")
l_to = link.get("to", "")
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
if from_match or to_match:
link_records.append((links_elem, index, link))
for links_parent, _, link in link_records:
links_parent.remove(link)
link_records = detach_links_touching(comp_element)
xml_parent = None
comp_index = 0
@@ -1177,7 +1173,18 @@ def execute_cascading_deletion(comp_element):
comp_index = list(xml_parent).index(comp_element)
xml_parent.remove(comp_element)
if xml_parent is not None and not app_state.live_mode:
if xml_parent is not None and app_state.live_mode:
if snapshot is not None:
undo.push(LiveComponentCommand(
xml_parent, comp_index, comp_element, present=False,
snapshot=snapshot, link_records=link_records,
label=f"delete {comp_name}"
))
else:
app_state.set_status(
f"{comp_name} was deleted from the device, but this delete cannot be "
f"undone: {unreadable}", warn=True)
elif xml_parent is not None:
undo.push(undo.DeleteComponentCommand(
xml_parent, comp_index, comp_element, link_records,
label=f"delete {comp_name}"
@@ -1195,3 +1202,156 @@ def execute_cascading_deletion(comp_element):
refresh_treeview_from_dom(app_state.xml_root_element)
wiresheet.render_components(app_state.active_components_list)
return True
def link_ends(from_ref, to_ref):
"""(from_elem, from_slot, to_elem, to_slot) for a link's "path.slot" ends."""
from_path, from_slot = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref)
to_path, to_slot = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref)
root = app_state.xml_root_element
return (find_component_by_path(root, from_path), from_slot,
find_component_by_path(root, to_path), to_slot)
def detach_links_touching(comp_element):
"""Takes every <link> into or out of `comp_element`'s subtree out of the DOM.
Returns (links_parent, index, link) records. Positions are recorded before anything
is removed, so putting them back in ascending order lands each on its old index.
"""
root = app_state.xml_root_element
if root is None:
return []
links_elem = root.find("links")
if links_elem is None:
return []
comp_path = get_component_path(comp_element, root)
link_records = []
for index, link in enumerate(list(links_elem)):
if link.tag != "link":
continue
l_from = link.get("from", "")
l_to = link.get("to", "")
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
if from_match or to_match:
link_records.append((links_elem, index, link))
for links_parent, _, link in link_records:
links_parent.remove(link)
return link_records
class LiveComponentCommand:
"""A component added to or deleted from the device, undone and redone through Sox.
Taking it away reads its whole subtree back first (config, children, links) and
then deletes it; putting it back adds all of that again. The device gives every
component a new id when it comes back, and the DOM elements take those ids on, so
later commands holding the same elements still reach the right components.
"""
def __init__(self, parent, index, element, present, snapshot=None, link_records=(),
label="add"):
# present: whether the component is on the device now. snapshot: from
# live_hooks.read_subtree, taken when it was last taken away.
self.parent = parent
self.index = index
self.element = element
self.present = present
self.snapshot = snapshot
self.link_records = list(link_records)
self.label = label
def undo(self):
self._flip()
def redo(self):
self._flip()
def _flip(self):
if self.present:
self._take_away()
else:
self._put_back()
def _take_away(self):
name = self.element.get("name", "?")
snapshot = live_hooks.read_subtree(self.element)
if snapshot is None:
raise undo.LiveUndoError(f"{name} was not deleted from the device")
if not live_hooks.live_delete_component(self.element):
raise undo.LiveUndoError(f"{name} was not deleted from the device")
self.snapshot = snapshot
self.present = False
self.link_records = detach_links_touching(self.element)
if self.element in list(self.parent):
self.index = list(self.parent).index(self.element)
self.parent.remove(self.element)
def _put_back(self):
name = self.element.get("name", "?")
created, complete = live_hooks.recreate_subtree(
self.parent, self.snapshot, build_config_values)
if not created:
raise undo.LiveUndoError(f"{name} was not put back on the device")
self.present = True
if self.element not in list(self.parent):
self.parent.insert(self.index, self.element)
for links_parent, index, link in sorted(self.link_records, key=lambda r: r[1]):
if link not in list(links_parent):
links_parent.insert(index, link)
ds = app_state.device_session
if ds is not None:
ds.update_element_from_cache(live_hooks._comp_id(self.element),
app_state.xml_root_element)
if not complete:
raise undo.LiveUndoError(f"{name} was not put back on the device in full")
class LiveLinkCommand:
"""A link made or removed on the device, undone and redone through Sox."""
def __init__(self, links_parent, index, link_element, present, label="add link"):
self.links_parent = links_parent
self.index = index
self.link_element = link_element
self.present = present
self.label = label
def undo(self):
self._flip()
def redo(self):
self._flip()
def _flip(self):
if self.present:
self._unlink()
else:
self._link()
def _describe(self):
return f"{self.link_element.get('from', '')} → {self.link_element.get('to', '')}"
def _ends(self):
return link_ends(self.link_element.get("from", ""), self.link_element.get("to", ""))
def _unlink(self):
if not live_hooks.live_delete_link(*self._ends()):
raise undo.LiveUndoError(f"The link {self._describe()} was not removed from the device")
self.present = False
if self.link_element in list(self.links_parent):
self.index = list(self.links_parent).index(self.link_element)
self.links_parent.remove(self.link_element)
def _link(self):
if not live_hooks.live_add_link(*self._ends()):
raise undo.LiveUndoError(f"The link {self._describe()} was not made on the device")
self.present = True
if self.link_element not in list(self.links_parent):
self.links_parent.insert(self.index, self.link_element)