This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 24274a9574 Switch pipeline and workflow undo/redo to XML snapshots, 
fixes #8037 (#8042)
24274a9574 is described below

commit 24274a957447a0b08e337b2515dec90dc19dff34
Author: Matt Casters <[email protected]>
AuthorDate: Fri Aug 21 09:28:48 2026 +0200

    Switch pipeline and workflow undo/redo to XML snapshots, fixes #8037 (#8042)
    
    Replace incremental ChangeAction replay with gzip-compressed document
    snapshots so undo/redo restores the whole pipeline or workflow instead of
    puzzling typed deltas back together. TableView cell undo is unchanged.
    
    Add a Maximum undo operations field on the Explorer Perspective options
    tab that reads and writes PropsUi.getMaxUndo().
---
 .../org/apache/hop/core/undo/XmlSnapshotUndo.java  | 227 +++++++++++
 .../java/org/apache/hop/pipeline/PipelineMeta.java |  17 +
 .../java/org/apache/hop/workflow/WorkflowMeta.java |  17 +
 .../apache/hop/core/undo/XmlSnapshotUndoTest.java  | 211 ++++++++++
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  59 ++-
 .../ui/hopgui/delegates/HopGuiUndoDelegate.java    |  28 +-
 .../file/delegates/HopGuiNotePadDelegate.java      |  39 +-
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  | 439 ++++++++++-----------
 .../delegates/HopGuiPipelineClipboardDelegate.java |  22 +-
 .../delegates/HopGuiPipelineTransformDelegate.java |  21 +-
 .../delegates/HopGuiPipelineUndoDelegate.java      | 306 +-------------
 .../ui/hopgui/file/shared/HopGuiAbstractGraph.java |  12 +
 .../file/shared/HopGuiGraphSnapshotUndo.java       | 218 ++++++++++
 .../hopgui/file/shared/ISnapshotUndoSupport.java   |  46 +++
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  | 218 +++++-----
 .../delegates/HopGuiWorkflowActionDelegate.java    |  10 +-
 .../delegates/HopGuiWorkflowClipboardDelegate.java |  19 +-
 .../delegates/HopGuiWorkflowUndoDelegate.java      | 304 +-------------
 .../config/ExplorerPerspectiveConfigPlugin.java    |  45 +++
 .../ui/hopgui/messages/messages_en_US.properties   |   4 +
 .../config/messages/messages_en_US.properties      |   3 +
 21 files changed, 1228 insertions(+), 1037 deletions(-)

diff --git a/engine/src/main/java/org/apache/hop/core/undo/XmlSnapshotUndo.java 
b/engine/src/main/java/org/apache/hop/core/undo/XmlSnapshotUndo.java
new file mode 100644
index 0000000000..80575c6563
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/core/undo/XmlSnapshotUndo.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.core.undo;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.IntSupplier;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+
+/**
+ * Gzip-compressed XML snapshots of a metadata document for undo/redo.
+ *
+ * <p>Unlike {@link ChangeAction}, this stores the whole document so restore 
cannot miss a field.
+ * Capture uses {@link XmlMetadataUtil#serializeObjectToXml(Object)} (no 
license header, no
+ * formatter) so snapshots stay small and stable.
+ */
+public class XmlSnapshotUndo<M> {
+
+  @FunctionalInterface
+  public interface ContentRestorer<M> {
+    void restore(M target, Node xmlRoot, IHopMetadataProvider 
metadataProvider, String filename)
+        throws HopException;
+  }
+
+  private final Class<M> modelClass;
+  private final String xmlRootTag;
+  private final ContentRestorer<M> restorer;
+  private final IntSupplier maxUndo;
+
+  private final List<byte[]> undoStack = new ArrayList<>();
+  private final List<byte[]> redoStack = new ArrayList<>();
+  private boolean applyingSnapshot;
+
+  public XmlSnapshotUndo(
+      Class<M> modelClass, String xmlRootTag, ContentRestorer<M> restorer, 
IntSupplier maxUndo) {
+    this.modelClass = modelClass;
+    this.xmlRootTag = xmlRootTag;
+    this.restorer = restorer;
+    this.maxUndo = maxUndo != null ? maxUndo : () -> Const.MAX_UNDO;
+  }
+
+  public void clear() {
+    undoStack.clear();
+    redoStack.clear();
+  }
+
+  public boolean canUndo() {
+    return !undoStack.isEmpty();
+  }
+
+  public boolean canRedo() {
+    return !redoStack.isEmpty();
+  }
+
+  public boolean isApplyingSnapshot() {
+    return applyingSnapshot;
+  }
+
+  public int getUndoSize() {
+    return undoStack.size();
+  }
+
+  public int getRedoSize() {
+    return redoStack.size();
+  }
+
+  public void markChange(M model, IHopMetadataProvider metadataProvider) 
throws HopException {
+    if (applyingSnapshot || model == null) {
+      return;
+    }
+    pushSnapshot(captureSnapshot(model, metadataProvider));
+  }
+
+  public void pushSnapshot(byte[] snapshot) {
+    if (applyingSnapshot || snapshot == null) {
+      return;
+    }
+    undoStack.add(snapshot);
+    trimStack(undoStack);
+    redoStack.clear();
+  }
+
+  public byte[] captureSnapshot(M model, IHopMetadataProvider metadataProvider)
+      throws HopException {
+    if (model == null) {
+      throw new HopException("Cannot capture snapshot of a null model");
+    }
+    try {
+      String xml = XmlHandler.aroundTag(xmlRootTag, 
XmlMetadataUtil.serializeObjectToXml(model));
+      return compress(xml);
+    } catch (Exception e) {
+      throw new HopException("Error capturing " + modelClass.getSimpleName() + 
" snapshot", e);
+    }
+  }
+
+  /**
+   * Restore the previous snapshot into {@code current}. The live document is 
pushed onto the redo
+   * stack first.
+   *
+   * @return {@code true} if a snapshot was applied
+   */
+  public boolean undo(M current, IHopMetadataProvider metadataProvider, String 
filename)
+      throws HopException {
+    if (!canUndo() || current == null) {
+      return false;
+    }
+    applyingSnapshot = true;
+    try {
+      redoStack.add(captureSnapshot(current, metadataProvider));
+      trimStack(redoStack);
+      byte[] previous = undoStack.remove(undoStack.size() - 1);
+      restoreInto(previous, current, metadataProvider, filename);
+      return true;
+    } finally {
+      applyingSnapshot = false;
+    }
+  }
+
+  /**
+   * Restore the next redo snapshot into {@code current}. The live document is 
pushed onto the undo
+   * stack first.
+   *
+   * @return {@code true} if a snapshot was applied
+   */
+  public boolean redo(M current, IHopMetadataProvider metadataProvider, String 
filename)
+      throws HopException {
+    if (!canRedo() || current == null) {
+      return false;
+    }
+    applyingSnapshot = true;
+    try {
+      undoStack.add(captureSnapshot(current, metadataProvider));
+      trimStack(undoStack);
+      byte[] next = redoStack.remove(redoStack.size() - 1);
+      restoreInto(next, current, metadataProvider, filename);
+      return true;
+    } finally {
+      applyingSnapshot = false;
+    }
+  }
+
+  public void restoreInto(
+      byte[] snapshot, M target, IHopMetadataProvider metadataProvider, String 
filename)
+      throws HopException {
+    try {
+      String xml = decompress(snapshot);
+      Document document = XmlHandler.loadXmlString(xml);
+      Node rootNode = XmlHandler.getSubNode(document, xmlRootTag);
+      if (rootNode == null) {
+        rootNode = document.getDocumentElement();
+      }
+      restorer.restore(target, rootNode, metadataProvider, filename);
+    } catch (Exception e) {
+      throw new HopException("Error restoring " + modelClass.getSimpleName() + 
" snapshot", e);
+    }
+  }
+
+  /**
+   * Gzip headers include a timestamp, so compressed bytes of identical XML 
are not equal. Compare
+   * inflated XML instead.
+   */
+  public static boolean sameXmlContent(byte[] left, byte[] right) {
+    if (left == right) {
+      return true;
+    }
+    if (left == null || right == null) {
+      return false;
+    }
+    try {
+      return decompress(left).equals(decompress(right));
+    } catch (IOException e) {
+      return false;
+    }
+  }
+
+  static byte[] compress(String xml) throws IOException {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream(xml.length());
+    try (GZIPOutputStream gzip = new GZIPOutputStream(baos)) {
+      gzip.write(xml.getBytes(StandardCharsets.UTF_8));
+    }
+    return baos.toByteArray();
+  }
+
+  static String decompress(byte[] compressed) throws IOException {
+    try (GZIPInputStream gzip = new GZIPInputStream(new 
ByteArrayInputStream(compressed));
+        ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+      gzip.transferTo(baos);
+      return baos.toString(StandardCharsets.UTF_8);
+    }
+  }
+
+  private void trimStack(List<byte[]> stack) {
+    int max = maxUndo.getAsInt();
+    if (max < 1) {
+      max = 1;
+    }
+    while (stack.size() > max) {
+      stack.remove(0);
+    }
+  }
+}
diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
index 4546493d80..d50f408754 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
@@ -1621,6 +1621,23 @@ public class PipelineMeta extends AbstractMeta
     clearChanged();
   }
 
+  /**
+   * Replace this pipeline's persisted content from a snapshot XML node. Used 
by GUI undo/redo.
+   *
+   * <p>Does not fire {@code PipelineMetaLoaded} (undo is not a file open) and 
does not call {@link
+   * #clearChanged()} — the caller decides the dirty flag.
+   */
+  public void restoreContentFromXml(
+      Node pipelineNode, String filename, IHopMetadataProvider 
metadataProvider)
+      throws HopException {
+    this.metadataProvider = metadataProvider;
+    clear();
+    setFilename(filename);
+    XmlMetadataUtil.deSerializeFromXml(
+        null, null, pipelineNode, PipelineMeta.class, this, metadataProvider);
+    lookupReferencesAfterLoading();
+  }
+
   private void deSerializeXml(
       Node pipelineNode,
       String filename,
diff --git a/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java 
b/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
index e21f72ac4b..854adda4d6 100644
--- a/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
+++ b/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
@@ -448,6 +448,23 @@ public class WorkflowMeta extends AbstractMeta
     }
   }
 
+  /**
+   * Replace this workflow's persisted content from a snapshot XML node. Used 
by GUI undo/redo.
+   *
+   * <p>Does not fire {@code WorkflowMetaLoaded} (undo is not a file open) and 
does not call {@link
+   * #clearChanged()} — the caller decides the dirty flag.
+   */
+  public void restoreContentFromXml(
+      Node workflowNode, String filename, IHopMetadataProvider 
metadataProvider)
+      throws HopException {
+    this.metadataProvider = metadataProvider;
+    clear();
+    setFilename(filename);
+    XmlMetadataUtil.deSerializeFromXml(
+        null, null, workflowNode, WorkflowMeta.class, this, metadataProvider);
+    lookupReferencesAfterLoading();
+  }
+
   /**
    * After loading there can still be some references to other transforms or 
indeed this pipeline
    * that need to be set. This is happening here.
diff --git 
a/engine/src/test/java/org/apache/hop/core/undo/XmlSnapshotUndoTest.java 
b/engine/src/test/java/org/apache/hop/core/undo/XmlSnapshotUndoTest.java
new file mode 100644
index 0000000000..53864cfdef
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/core/undo/XmlSnapshotUndoTest.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.core.undo;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.NotePadMeta;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.dummy.DummyMeta;
+import org.apache.hop.workflow.WorkflowHopMeta;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.apache.hop.workflow.actions.dummy.ActionDummy;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class XmlSnapshotUndoTest {
+
+  private final MemoryMetadataProvider metadataProvider = new 
MemoryMetadataProvider();
+
+  @BeforeAll
+  static void setUpBeforeClass() throws Exception {
+    HopEnvironment.init();
+  }
+
+  @Test
+  void pipelineRoundTripUndoRedoAndRedoClearedOnNewChange() throws Exception {
+    PipelineMeta pipelineMeta = samplePipeline("before");
+    pipelineMeta.setNameSynchronizedWithFilename(false);
+    pipelineMeta.setFilename("/tmp/sample.hpl");
+    XmlSnapshotUndo<PipelineMeta> undo = pipelineUndo(10);
+
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.setName("after");
+    pipelineMeta.getTransform(0).setLocation(200, 200);
+
+    assertTrue(undo.canUndo());
+    assertFalse(undo.canRedo());
+
+    assertTrue(undo.undo(pipelineMeta, metadataProvider, "/tmp/sample.hpl"));
+    assertEquals("before", pipelineMeta.getName());
+    assertEquals(50, pipelineMeta.getTransform(0).getLocation().x);
+    assertEquals("/tmp/sample.hpl", pipelineMeta.getFilename());
+    assertEquals("A", pipelineMeta.getTransform(0).getName());
+    assertEquals("B", pipelineMeta.getTransform(1).getName());
+    assertEquals("A", 
pipelineMeta.getPipelineHop(0).getFromTransform().getName());
+    assertEquals("B", 
pipelineMeta.getPipelineHop(0).getToTransform().getName());
+    assertEquals("note", pipelineMeta.getNote(0).getNote());
+    assertTrue(undo.canRedo());
+
+    assertTrue(undo.redo(pipelineMeta, metadataProvider, "/tmp/sample.hpl"));
+    assertEquals("after", pipelineMeta.getName());
+    assertEquals(200, pipelineMeta.getTransform(0).getLocation().x);
+    assertEquals("/tmp/sample.hpl", pipelineMeta.getFilename());
+
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.setName("newer");
+    assertFalse(undo.canRedo());
+    assertTrue(undo.undo(pipelineMeta, metadataProvider, "/tmp/sample.hpl"));
+    assertEquals("after", pipelineMeta.getName());
+  }
+
+  @Test
+  void workflowRoundTripRestoresHopsAndNotes() throws Exception {
+    WorkflowMeta workflowMeta = sampleWorkflow("wf-before");
+    workflowMeta.setNameSynchronizedWithFilename(false);
+    workflowMeta.setFilename("/tmp/sample.hwf");
+    XmlSnapshotUndo<WorkflowMeta> undo = workflowUndo(10);
+
+    undo.markChange(workflowMeta, metadataProvider);
+    workflowMeta.setName("wf-after");
+    workflowMeta.removeWorkflowHop(0);
+
+    assertTrue(undo.undo(workflowMeta, metadataProvider, "/tmp/sample.hwf"));
+    assertEquals("wf-before", workflowMeta.getName());
+    assertEquals(1, workflowMeta.nrWorkflowHops());
+    assertEquals("start", 
workflowMeta.getWorkflowHop(0).getFromAction().getName());
+    assertEquals("dummy", 
workflowMeta.getWorkflowHop(0).getToAction().getName());
+    assertEquals("hello", workflowMeta.getNote(0).getNote());
+    assertEquals("/tmp/sample.hwf", workflowMeta.getFilename());
+  }
+
+  @Test
+  void trimHonorsMaxUndo() throws Exception {
+    PipelineMeta pipelineMeta = samplePipeline("v0");
+    XmlSnapshotUndo<PipelineMeta> undo = pipelineUndo(2);
+
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.setName("v1");
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.setName("v2");
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.setName("v3");
+
+    assertEquals(2, undo.getUndoSize());
+    assertTrue(undo.undo(pipelineMeta, metadataProvider, null));
+    assertEquals("v2", pipelineMeta.getName());
+    assertTrue(undo.undo(pipelineMeta, metadataProvider, null));
+    assertEquals("v1", pipelineMeta.getName());
+    assertFalse(undo.undo(pipelineMeta, metadataProvider, null));
+  }
+
+  @Test
+  void applyingSnapshotDoesNotRecord() throws Exception {
+    PipelineMeta pipelineMeta = samplePipeline("orig");
+    AtomicInteger restoreCalls = new AtomicInteger();
+    XmlSnapshotUndo<PipelineMeta> undo =
+        new XmlSnapshotUndo<>(
+            PipelineMeta.class,
+            PipelineMeta.XML_TAG,
+            (target, node, provider, filename) -> {
+              restoreCalls.incrementAndGet();
+              target.restoreContentFromXml(node, filename, provider);
+            },
+            () -> 10);
+
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.setName("changed");
+    assertTrue(undo.undo(pipelineMeta, metadataProvider, null));
+    assertEquals(1, restoreCalls.get());
+    // Nested mark during restore is skipped because applyingSnapshot is true.
+    assertEquals(1, undo.getRedoSize());
+    assertEquals(0, undo.getUndoSize());
+  }
+
+  @Test
+  void sameXmlContentIgnoresGzipHeaderTimestamp() throws Exception {
+    PipelineMeta pipelineMeta = samplePipeline("same");
+    XmlSnapshotUndo<PipelineMeta> undo = pipelineUndo(5);
+    byte[] first = undo.captureSnapshot(pipelineMeta, metadataProvider);
+    byte[] second = undo.captureSnapshot(pipelineMeta, metadataProvider);
+    assertTrue(XmlSnapshotUndo.sameXmlContent(first, second));
+  }
+
+  @Test
+  void restoreDoesNotRequireClone() throws Exception {
+    PipelineMeta pipelineMeta = samplePipeline("clone-free");
+    XmlSnapshotUndo<PipelineMeta> undo = pipelineUndo(5);
+    undo.markChange(pipelineMeta, metadataProvider);
+    pipelineMeta.addTransform(new TransformMeta("C", new DummyMeta()));
+    assertTrue(undo.undo(pipelineMeta, metadataProvider, null));
+    assertEquals(2, pipelineMeta.nrTransforms());
+  }
+
+  private static XmlSnapshotUndo<PipelineMeta> pipelineUndo(int max) {
+    return new XmlSnapshotUndo<>(
+        PipelineMeta.class,
+        PipelineMeta.XML_TAG,
+        (target, node, provider, filename) ->
+            target.restoreContentFromXml(node, filename, provider),
+        () -> max);
+  }
+
+  private static XmlSnapshotUndo<WorkflowMeta> workflowUndo(int max) {
+    return new XmlSnapshotUndo<>(
+        WorkflowMeta.class,
+        WorkflowMeta.XML_TAG,
+        (target, node, provider, filename) ->
+            target.restoreContentFromXml(node, filename, provider),
+        () -> max);
+  }
+
+  private static PipelineMeta samplePipeline(String name) {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName(name);
+    TransformMeta a = new TransformMeta("A", new DummyMeta());
+    a.setLocation(50, 50);
+    TransformMeta b = new TransformMeta("B", new DummyMeta());
+    b.setLocation(150, 50);
+    pipelineMeta.addTransform(a);
+    pipelineMeta.addTransform(b);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(a, b));
+    pipelineMeta.addNote(new NotePadMeta("note", 10, 10, 80, 40));
+    return pipelineMeta;
+  }
+
+  private static WorkflowMeta sampleWorkflow(String name) {
+    WorkflowMeta workflowMeta = new WorkflowMeta();
+    workflowMeta.setName(name);
+    ActionMeta start = new ActionMeta(new ActionDummy("start"));
+    start.setLocation(50, 50);
+    ActionMeta dummy = new ActionMeta(new ActionDummy("dummy"));
+    dummy.setLocation(150, 50);
+    workflowMeta.addAction(start);
+    workflowMeta.addAction(dummy);
+    workflowMeta.addWorkflowHop(new WorkflowHopMeta(start, dummy));
+    workflowMeta.addNote(new NotePadMeta("hello", 10, 10, 80, 40));
+    return workflowMeta;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 29b3a32cd7..4aa4225097 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -119,6 +119,7 @@ import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.empty.EmptyFileType;
 import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
 import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
 import org.apache.hop.ui.hopgui.perspective.EmptyHopPerspective;
 import org.apache.hop.ui.hopgui.perspective.HopPerspectiveManager;
@@ -2053,40 +2054,60 @@ public class HopGui
   }
 
   public void setUndoMenu(IUndo undoInterface) {
-    // Grab the undo and redo menu items...
-    //
-    MenuItem undoItem = mainMenuWidgets.findMenuItem(ID_MAIN_MENU_EDIT_UNDO);
-    MenuItem redoItem = mainMenuWidgets.findMenuItem(ID_MAIN_MENU_EDIT_REDO);
-    if (undoItem == null || redoItem == null || undoItem.isDisposed() || 
redoItem.isDisposed()) {
-      return;
+    try {
+      IHopFileTypeHandler handler = getActiveFileTypeHandler();
+      if (handler instanceof ISnapshotUndoSupport support
+          && (undoInterface == null || support.isUndoMeta(undoInterface))) {
+        setUndoMenu(support.canUndo(), support.canRedo());
+        return;
+      }
+    } catch (Exception e) {
+      // Menu is built before a file handler exists.
     }
 
-    ChangeAction prev = null;
-    ChangeAction next = null;
+    ChangeAction prev = undoInterface != null ? undoInterface.viewThisUndo() : 
null;
+    ChangeAction next = undoInterface != null ? undoInterface.viewNextUndo() : 
null;
+    setUndoMenuItems(prev != null, next != null, prev, next);
+  }
 
-    if (undoInterface != null) {
-      prev = undoInterface.viewThisUndo();
-      next = undoInterface.viewNextUndo();
+  public void setUndoMenu(boolean canUndo, boolean canRedo) {
+    setUndoMenuItems(canUndo, canRedo, null, null);
+  }
+
+  private void setUndoMenuItems(
+      boolean canUndo, boolean canRedo, ChangeAction prev, ChangeAction next) {
+    GuiMenuWidgets widgets = getMainMenuWidgets();
+    if (widgets == null) {
+      return;
+    }
+    MenuItem undoItem = widgets.findMenuItem(ID_MAIN_MENU_EDIT_UNDO);
+    MenuItem redoItem = widgets.findMenuItem(ID_MAIN_MENU_EDIT_REDO);
+    if (undoItem == null || redoItem == null || undoItem.isDisposed() || 
redoItem.isDisposed()) {
+      return;
     }
 
-    undoItem.setEnabled(prev != null);
-    if (prev == null) {
+    undoItem.setEnabled(canUndo);
+    if (!canUndo) {
       undoItem.setText(UNDO_UNAVAILABLE);
-    } else {
+    } else if (prev != null) {
       undoItem.setText(BaseMessages.getString(PKG, 
"HopGui.Menu.Undo.Available", prev.toString()));
+    } else {
+      undoItem.setText(BaseMessages.getString(PKG, "HopGui.Menu.Edit.Undo"));
     }
-    KeyboardShortcut undoShortcut = 
mainMenuWidgets.findKeyboardShortcut(ID_MAIN_MENU_EDIT_UNDO);
+    KeyboardShortcut undoShortcut = 
widgets.findKeyboardShortcut(ID_MAIN_MENU_EDIT_UNDO);
     if (undoShortcut != null) {
       GuiMenuWidgets.appendShortCut(undoItem, undoShortcut);
     }
 
-    redoItem.setEnabled(next != null);
-    if (next == null) {
+    redoItem.setEnabled(canRedo);
+    if (!canRedo) {
       redoItem.setText(REDO_UNAVAILABLE);
-    } else {
+    } else if (next != null) {
       redoItem.setText(BaseMessages.getString(PKG, 
"HopGui.Menu.Redo.Available", next.toString()));
+    } else {
+      redoItem.setText(BaseMessages.getString(PKG, "HopGui.Menu.Edit.Redo"));
     }
-    KeyboardShortcut redoShortcut = 
mainMenuWidgets.findKeyboardShortcut(ID_MAIN_MENU_EDIT_REDO);
+    KeyboardShortcut redoShortcut = 
widgets.findKeyboardShortcut(ID_MAIN_MENU_EDIT_REDO);
     if (redoShortcut != null) {
       GuiMenuWidgets.appendShortCut(redoItem, redoShortcut);
     }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiUndoDelegate.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiUndoDelegate.java
index acf149dc40..b8c4e46ec2 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiUndoDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiUndoDelegate.java
@@ -22,6 +22,8 @@ import org.apache.hop.core.IAddUndoPosition;
 import org.apache.hop.core.gui.IUndo;
 import org.apache.hop.core.gui.Point;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
 
 public class HopGuiUndoDelegate implements IAddUndoPosition {
   private HopGui hopGui;
@@ -35,6 +37,9 @@ public class HopGuiUndoDelegate implements IAddUndoPosition {
   }
 
   public void addUndoNew(IUndo undoInterface, Object[] obj, int[] position, 
boolean nextAlso) {
+    if (recordSnapshot(undoInterface, nextAlso)) {
+      return;
+    }
     undoInterface.addUndo(obj, null, position, null, null, 
AbstractMeta.TYPE_UNDO_NEW, nextAlso);
     hopGui.setUndoMenu(undoInterface);
   }
@@ -46,6 +51,9 @@ public class HopGuiUndoDelegate implements IAddUndoPosition {
 
   // Undo delete object
   public void addUndoDelete(IUndo undoInterface, Object[] obj, int[] position, 
boolean nextAlso) {
+    if (recordSnapshot(undoInterface, nextAlso)) {
+      return;
+    }
     undoInterface.addUndo(obj, null, position, null, null, 
AbstractMeta.TYPE_UNDO_DELETE, nextAlso);
     hopGui.setUndoMenu(undoInterface);
   }
@@ -60,9 +68,10 @@ public class HopGuiUndoDelegate implements IAddUndoPosition {
   // Change of transform, connection, hop or note...
   public void addUndoPosition(
       IUndo undoInterface, Object[] obj, int[] pos, Point[] prev, Point[] 
curr, boolean nextAlso) {
-    // It's better to store the indexes of the objects, not the objects
-    // itself!
-    undoInterface.addUndo(obj, null, pos, prev, curr, 
AbstractMeta.TYPE_UNDO_POSITION, false);
+    if (recordSnapshot(undoInterface, nextAlso)) {
+      return;
+    }
+    undoInterface.addUndo(obj, null, pos, prev, curr, 
AbstractMeta.TYPE_UNDO_POSITION, nextAlso);
     hopGui.setUndoMenu(undoInterface);
   }
 
@@ -74,10 +83,23 @@ public class HopGuiUndoDelegate implements IAddUndoPosition 
{
   // Change of transform, connection, hop or note...
   public void addUndoChange(
       IUndo undoInterface, Object[] from, Object[] to, int[] pos, boolean 
nextAlso) {
+    if (recordSnapshot(undoInterface, nextAlso)) {
+      return;
+    }
     undoInterface.addUndo(from, to, pos, null, null, 
AbstractMeta.TYPE_UNDO_CHANGE, nextAlso);
     hopGui.setUndoMenu(undoInterface);
   }
 
+  private boolean recordSnapshot(IUndo undoInterface, boolean nextAlso) {
+    IHopFileTypeHandler handler = hopGui.getActiveFileTypeHandler();
+    if (handler instanceof ISnapshotUndoSupport support && 
support.isUndoMeta(undoInterface)) {
+      support.recordAfterChange(nextAlso);
+      hopGui.setUndoMenu(support.canUndo(), support.canRedo());
+      return true;
+    }
+    return false;
+  }
+
   /**
    * Gets hopGui
    *
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
index a8c71e6090..0060d1a70d 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/delegates/HopGuiNotePadDelegate.java
@@ -30,6 +30,7 @@ import org.apache.hop.ui.core.security.HopSecurityUi;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.dialog.NotePadDialog;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
 
 public class HopGuiNotePadDelegate {
   private static final Class<?> PKG = HopGui.class;
@@ -56,17 +57,13 @@ public class HopGuiNotePadDelegate {
     if (!HopSecurityUi.check(Permission.FILE_EDIT)) {
       return;
     }
-    int[] idxs = new int[notes.size()];
-    NotePadMeta[] noteCopies = new NotePadMeta[notes.size()];
-    for (int i = 0; i < idxs.length; i++) {
-      idxs[i] = meta.indexOfNote(notes.get(i));
-      noteCopies[i] = new NotePadMeta(notes.get(i));
-    }
+    markUndo(meta);
     for (NotePadMeta notePadMeta : notes) {
       int idx = meta.indexOfNote(notePadMeta);
-      meta.removeNote(idx);
+      if (idx >= 0) {
+        meta.removeNote(idx);
+      }
     }
-    hopGui.undoDelegate.addUndoDelete(meta, noteCopies, idxs);
     handler.updateGui();
   }
 
@@ -76,13 +73,31 @@ public class HopGuiNotePadDelegate {
     }
     int idx = meta.indexOfNote(notePadMeta);
     if (idx >= 0) {
+      markUndo(meta);
       meta.removeNote(idx);
-      hopGui.undoDelegate.addUndoDelete(
-          meta, new NotePadMeta[] {(NotePadMeta) notePadMeta.clone()}, new 
int[] {idx});
     }
     handler.updateGui();
   }
 
+  private void markUndo(AbstractMeta meta) {
+    if (handler instanceof ISnapshotUndoSupport support && 
support.isUndoMeta(meta)) {
+      support.markUndoPoint();
+    }
+  }
+
+  private byte[] captureUndo(AbstractMeta meta) {
+    if (handler instanceof ISnapshotUndoSupport support && 
support.isUndoMeta(meta)) {
+      return support.captureUndoSnapshot();
+    }
+    return null;
+  }
+
+  private void commitUndo(AbstractMeta meta, byte[] beforeSnapshot) {
+    if (handler instanceof ISnapshotUndoSupport support && 
support.isUndoMeta(meta)) {
+      support.commitDialogUndo(beforeSnapshot);
+    }
+  }
+
   public void newNote(IVariables variables, AbstractMeta meta, int x, int y) {
     if (!HopSecurityUi.check(Permission.FILE_EDIT)) {
       return;
@@ -90,6 +105,7 @@ public class HopGuiNotePadDelegate {
     String title = BaseMessages.getString(PKG, 
"PipelineGraph.Dialog.NoteEditor.Title");
     NotePadDialog dialog =
         new NotePadDialog(variables, hopGui.getShell(), title, 
meta.getFilename());
+    byte[] beforeSnapshot = captureUndo(meta);
     NotePadMeta note = dialog.open();
     if (note != null) {
       NotePadMeta newNote =
@@ -117,8 +133,7 @@ public class HopGuiNotePadDelegate {
       // Apply grid snapping; default width is readable for Markdown wrapping
       PropsUi.setSize(newNote, defaultNoteWidth(), ConstUi.NOTE_MIN_SIZE);
       meta.addNote(newNote);
-      hopGui.undoDelegate.addUndoNew(
-          meta, new NotePadMeta[] {newNote}, new int[] 
{meta.indexOfNote(newNote)});
+      commitUndo(meta, beforeSnapshot);
       handler.updateGui();
     }
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index e5e56bc139..eda1c64c9c 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -17,6 +17,8 @@
 
 package org.apache.hop.ui.hopgui.file.pipeline;
 
+import static java.lang.Thread.sleep;
+
 import java.io.OutputStream;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.InvocationTargetException;
@@ -61,6 +63,7 @@ import org.apache.hop.core.gui.CanvasSvgRenderResult;
 import org.apache.hop.core.gui.DPoint;
 import org.apache.hop.core.gui.IGc;
 import org.apache.hop.core.gui.IRedrawable;
+import org.apache.hop.core.gui.IUndo;
 import org.apache.hop.core.gui.Point;
 import org.apache.hop.core.gui.Rectangle;
 import org.apache.hop.core.gui.SnapAllignDistribute;
@@ -189,7 +192,9 @@ import 
org.apache.hop.ui.hopgui.file.pipeline.extension.HopGuiPipelineGraphExten
 import 
org.apache.hop.ui.hopgui.file.pipeline.extension.PipelineRenamedExtension;
 import org.apache.hop.ui.hopgui.file.shared.DrillDownGuiPlugin;
 import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
+import org.apache.hop.ui.hopgui.file.shared.HopGuiGraphSnapshotUndo;
 import org.apache.hop.ui.hopgui.file.shared.HopGuiTooltipExtension;
+import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
 import org.apache.hop.ui.hopgui.file.shared.PipelineRowSamplerHelper;
 import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
 import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
@@ -252,7 +257,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
         ILogParentProvided,
         IHopFileTypeHandler,
         IGuiRefresher,
-        IWebCanvasGraph {
+        IWebCanvasGraph,
+        ISnapshotUndoSupport {
 
   private static final Class<?> PKG = HopGui.class;
 
@@ -441,6 +447,7 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
   public HopGuiPipelineClipboardDelegate pipelineClipboardDelegate;
   public HopGuiPipelineHopDelegate pipelineHopDelegate;
   public HopGuiPipelineUndoDelegate pipelineUndoDelegate;
+  private final HopGuiGraphSnapshotUndo<PipelineMeta> snapshotUndo;
 
   public HopGuiServerDelegate serverDelegate;
   public HopGuiNotePadDelegate notePadDelegate;
@@ -522,6 +529,17 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     pipelineTransformDelegate = new HopGuiPipelineTransformDelegate(hopGui, 
this);
     pipelineHopDelegate = new HopGuiPipelineHopDelegate(hopGui, this);
     pipelineUndoDelegate = new HopGuiPipelineUndoDelegate(hopGui, this);
+    snapshotUndo =
+        new HopGuiGraphSnapshotUndo<>(
+            hopGui,
+            PipelineMeta.class,
+            PipelineMeta.XML_TAG,
+            (target, node, provider, filename) ->
+                target.restoreContentFromXml(node, filename, provider),
+            () -> this.pipelineMeta,
+            this::getFilename,
+            this::restoreAfterSnapshot);
+    snapshotUndo.initialize();
     pipelineRunDelegate = new HopGuiPipelineRunDelegate(hopGui, this);
 
     serverDelegate = new HopGuiServerDelegate(hopGui, this);
@@ -952,6 +970,7 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
             // native SWT keeps the threshold behaviour to distinguish a click 
from a drag.
             if (EnvironmentUtils.getInstance().isWeb() && event.button == 1 && 
!shift && !control) {
               iconDragCommitted = true;
+              markPositionUndoPoint();
               dragSelection = true;
               canvas.setData("mode", "drag");
               selectedTransforms = pipelineMeta.getSelectedTransforms();
@@ -1316,33 +1335,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
             pipelineGridDelegate.onPipelineSelectionChanged();
           }
 
-          // We moved around some items: store undo info...
-          //
-          boolean also = false;
-          if (!Utils.isEmpty(selectedNotes) && previousNoteLocations != null) {
-            int[] indexes = pipelineMeta.getNoteIndexes(selectedNotes);
-
-            also = !Utils.isEmpty(selectedTransforms);
-            hopGui.undoDelegate.addUndoPosition(
-                pipelineMeta,
-                selectedNotes.toArray(new NotePadMeta[0]),
-                indexes,
-                previousNoteLocations,
-                pipelineMeta.getSelectedNoteLocations(),
-                also);
-          }
-          if (selectedTransforms != null
-              && !selectedTransforms.isEmpty()
-              && previousTransformLocations != null) {
-            int[] indexes = 
pipelineMeta.getTransformIndexes(selectedTransforms);
-            hopGui.undoDelegate.addUndoPosition(
-                pipelineMeta,
-                selectedTransforms.toArray(new TransformMeta[0]),
-                indexes,
-                previousTransformLocations,
-                pipelineMeta.getSelectedTransformLocations(),
-                also);
-          }
+          // Position undo was recorded at drag start via 
markPositionUndoPoint().
         }
       }
 
@@ -1366,6 +1359,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       dragSelection = false;
       iconDragStartScreen = null;
       iconDragCommitted = false;
+      resetPositionUndoMark();
       removePlacementDragFilters();
 
       updateGui();
@@ -1406,34 +1400,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
               pipelineGridDelegate.onPipelineSelectionChanged();
             }
 
-            // We moved around some items: store undo info...
-
-            boolean also = false;
-            if (selectedNotes != null
-                && !selectedNotes.isEmpty()
-                && previousNoteLocations != null) {
-              int[] indexes = pipelineMeta.getNoteIndexes(selectedNotes);
-              hopGui.undoDelegate.addUndoPosition(
-                  pipelineMeta,
-                  selectedNotes.toArray(new NotePadMeta[selectedNotes.size()]),
-                  indexes,
-                  previousNoteLocations,
-                  pipelineMeta.getSelectedNoteLocations(),
-                  also);
-              also = !Utils.isEmpty(selectedTransforms);
-            }
-            if (selectedTransforms != null
-                && !selectedTransforms.isEmpty()
-                && previousTransformLocations != null) {
-              int[] indexes = 
pipelineMeta.getTransformIndexes(selectedTransforms);
-              hopGui.undoDelegate.addUndoPosition(
-                  pipelineMeta,
-                  selectedTransforms.toArray(new 
TransformMeta[selectedTransforms.size()]),
-                  indexes,
-                  previousTransformLocations,
-                  pipelineMeta.getSelectedTransformLocations(),
-                  also);
-            }
+            // Position undo was recorded at drag start via 
markPositionUndoPoint().
           }
         }
 
@@ -2239,6 +2206,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       int thresholdSq = ICON_DRAG_THRESHOLD_PX * ICON_DRAG_THRESHOLD_PX;
       if (dx * dx + dy * dy > thresholdSq) {
         iconDragCommitted = true;
+        markPositionUndoPoint();
         canvas.setData("mode", "drag");
         dragSelection = true;
         selectedTransforms = pipelineMeta.getSelectedTransforms();
@@ -2368,7 +2336,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
                 showToolTip(new org.eclipse.swt.graphics.Point(event.x, 
event.y));
               }
             }
-          } else if (endHopTransform != null) {
+          } else {
             if (ioMeta.isOutputProducer()) {
               candidate = new PipelineHopMeta(transformMeta, endHopTransform);
               endHopLocation = null;
@@ -2396,6 +2364,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
        *
        * new : new position of the note (not the mouse pointer) dx : 
difference with previous position
        */
+      markPositionUndoPoint();
       int dx = note.x - selectedNote.getLocation().x;
       int dy = note.y - selectedNote.getLocation().y;
 
@@ -2498,6 +2467,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
     Point[] transformsBefore = captureLocations(transforms);
     Point[] notesBefore = captureNoteLocations(notes);
+    byte[] beforeSnapshot = captureUndoSnapshot();
 
     moveSelected(dx, dy);
 
@@ -2509,28 +2479,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       return true;
     }
 
-    // Record notes first, then transforms, linked into a single undo action 
(nextAlso).
-    boolean also = false;
-    if (!Utils.isEmpty(notes)) {
-      also = !Utils.isEmpty(transforms);
-      hopGui.undoDelegate.addUndoPosition(
-          pipelineMeta,
-          notes.toArray(new NotePadMeta[0]),
-          pipelineMeta.getNoteIndexes(notes),
-          notesBefore,
-          notesAfter,
-          also);
-    }
-    if (!Utils.isEmpty(transforms)) {
-      hopGui.undoDelegate.addUndoPosition(
-          pipelineMeta,
-          transforms.toArray(new TransformMeta[0]),
-          pipelineMeta.getTransformIndexes(transforms),
-          transformsBefore,
-          transformsAfter,
-          also);
-    }
-
+    commitDialogUndo(beforeSnapshot);
     pipelineMeta.setChanged();
     updateGui();
     return true;
@@ -2663,6 +2612,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     Display disp = hopDisplay();
     SwtUniversalImage swtImage =
         
SwtGc.getNativeImage(BasePainter.getStreamIconImage(stream.getStreamIcon(), 
true));
+    assert swtImage != null;
     return swtImage.getAsBitmapForSize(disp, ConstUi.SMALL_ICON_SIZE, 
ConstUi.SMALL_ICON_SIZE);
   }
 
@@ -2849,48 +2799,18 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     selectionRegion = null;
 
     List<TransformMeta> subset = null;
-    List<TransformMeta> moving;
     if (selectionOnly) {
       subset = pipelineMeta.getSelectedTransforms();
       if (subset == null || subset.size() < 2) {
         return; // Nothing meaningful to arrange.
       }
-      moving = new ArrayList<>(subset);
-    } else {
-      int n = pipelineMeta.nrTransforms();
-      if (n == 0) {
-        return;
-      }
-      moving = new ArrayList<>(n);
-      for (int i = 0; i < n; i++) {
-        moving.add(pipelineMeta.getTransform(i));
-      }
+    } else if (pipelineMeta.nrTransforms() == 0) {
+      return;
     }
 
-    // Auto-layout may also reposition notes; capture them so the whole thing 
is one undo step.
-    List<NotePadMeta> notes = new ArrayList<>(pipelineMeta.getNotes());
-    Point[] notesBefore = captureNoteLocations(notes);
-
-    Point[] before = captureLocations(moving);
+    byte[] beforeSnapshot = captureUndoSnapshot();
     PipelineMetaLayout.layout(pipelineMeta, 
PropsUi.getInstance().getAutoLayoutOptions(), subset);
-    Point[] after = captureLocations(moving);
-    Point[] notesAfter = captureNoteLocations(notes);
-
-    // Record notes first, then transforms, linked into a single undo action 
(nextAlso).
-    boolean also = false;
-    if (!notes.isEmpty()) {
-      also = true;
-      hopGui.undoDelegate.addUndoPosition(
-          pipelineMeta,
-          notes.toArray(new NotePadMeta[0]),
-          pipelineMeta.getNoteIndexes(notes),
-          notesBefore,
-          notesAfter,
-          also);
-    }
-    int[] indexes = pipelineMeta.getTransformIndexes(moving);
-    hopGui.undoDelegate.addUndoPosition(
-        pipelineMeta, moving.toArray(new TransformMeta[0]), indexes, before, 
after, also);
+    commitDialogUndo(beforeSnapshot);
 
     pipelineMeta.setChanged();
     updateGui();
@@ -2948,10 +2868,6 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return PaletteEngineFilter.getPipelineEngineLabels();
   }
 
-  /**
-   * Push the persisted design-engine label into the toolbar combo so the user 
sees their previous
-   * choice on every new tab. Called from {@link #addToolBar} after the 
widgets are created.
-   */
   /**
    * Dispose the combo ToolItem and the preceding label-separator ToolItem the 
toolbar framework
    * inserts for any item whose {@code @GuiToolbarElement.label} is non-empty 
(see {@code
@@ -3490,7 +3406,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     EnterSelectionDialog dialog =
         new EnterSelectionDialog(
             hopShell(),
-            choices.toArray(new String[choices.size()]),
+            choices.toArray(new String[0]),
             BaseMessages.getString(PKG, 
"HopGuiPipelineGraph.DistributionMethodDialog.Header"),
             BaseMessages.getString(PKG, 
"HopGuiPipelineGraph.DistributionMethodDialog.Text"));
     if (dialog.open() != null) {
@@ -3536,7 +3452,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       return true;
     }
     // Variable pattern: '${VARIABLE}'
-    return trimmed.matches("\\$\\{[^}]+\\}");
+    return trimmed.matches("\\$\\{[^}]+}");
   }
 
   public void copies(TransformMeta transformMeta) {
@@ -3573,7 +3489,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
             SWT.YES | SWT.ICON_WARNING);
       }
       String cps = transformMeta.getCopiesString();
-      if ((cps != null && !cps.equals(cop)) || (cps == null && cop != null)) {
+      if (cps == null || !cps.equals(cop)) {
         transformMeta.setChanged();
       }
       transformMeta.setCopiesString(cop);
@@ -3659,8 +3575,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
    * We're filtering out the disable action for hops which are already 
disabled. The same for the
    * enabled hops.
    *
-   * @param contextActionId
-   * @param context
+   * @param contextActionId The context action ID to verify
+   * @param context The context to use
    * @return True if the action should be shown and false otherwise.
    */
   @GuiContextActionFilter(parentId = HopGuiPipelineHopContext.CONTEXT_ID)
@@ -3678,8 +3594,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   /**
    * We're filtering out certain actions for transforms which don't make sense.
    *
-   * @param contextActionId
-   * @param context
+   * @param contextActionId The context action ID to verify
+   * @param context The context
    * @return True if the action should be shown and false otherwise.
    */
   @GuiContextActionFilter(parentId = HopGuiPipelineTransformContext.CONTEXT_ID)
@@ -4073,7 +3989,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       categoryOrder = "1")
   public void copyNotePadToClipboard(HopGuiPipelineNoteContext context) {
     pipelineClipboardDelegate.copySelected(
-        pipelineMeta, Collections.emptyList(), 
Arrays.asList(context.getNotePadMeta()));
+        pipelineMeta, Collections.emptyList(), 
Collections.singletonList(context.getNotePadMeta()));
   }
 
   @GuiContextAction(
@@ -4169,6 +4085,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           if (!Utils.isEmpty(targetTransforms[t])
               && 
targetTransforms[t].equalsIgnoreCase(transformMeta.getName())) {
             enabled = false;
+            break;
           }
         }
       }
@@ -4176,11 +4093,11 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return enabled;
   }
 
-  private AreaOwner setToolTip(int x, int y, int screenX, int screenY) {
+  private void setToolTip(int x, int y, int screenX, int screenY) {
     AreaOwner subject = null;
 
     if (!hopGui.getProps().showToolTips() || openedContextDialog) {
-      return subject;
+      return;
     }
 
     canvas.setToolTipText(null);
@@ -4204,17 +4121,20 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           }
           break;
         case TRANSFORM_PARTITIONING:
-          TransformMeta transform = (TransformMeta) areaOwner.getParent();
-          tip.append("Transform partitioning:")
-              .append(Const.CR)
-              .append("-----------------------")
-              .append(Const.CR);
-          
tip.append(transform.getTransformPartitioningMeta().toString()).append(Const.CR);
-          if (transform.getTargetTransformPartitioningMeta() != null) {
-            tip.append(Const.CR)
+          {
+            TransformMeta transform = (TransformMeta) areaOwner.getParent();
+            tip.append("Transform partitioning:")
                 .append(Const.CR)
-                .append("TARGET: " + 
transform.getTargetTransformPartitioningMeta().toString())
+                .append("-----------------------")
                 .append(Const.CR);
+            
tip.append(transform.getTransformPartitioningMeta().toString()).append(Const.CR);
+            if (transform.getTargetTransformPartitioningMeta() != null) {
+              tip.append(Const.CR)
+                  .append(Const.CR)
+                  .append("TARGET: ")
+                  
.append(transform.getTargetTransformPartitioningMeta().toString())
+                  .append(Const.CR);
+            }
           }
           break;
         case TRANSFORM_FAILURE_ICON:
@@ -4223,73 +4143,84 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           tipImage = GuiResource.getInstance().getImageFailure();
           break;
         case HOP_COPY_ICON:
-          transform = (TransformMeta) areaOwner.getParent();
-          tip.append(
-              BaseMessages.getString(
-                  PKG, "PipelineGraph.Hop.Tooltip.HopTypeCopy", 
transform.getName(), Const.CR));
-          tipImage = GuiResource.getInstance().getImageCopyHop();
+          {
+            TransformMeta transform = (TransformMeta) areaOwner.getParent();
+            tip.append(
+                BaseMessages.getString(
+                    PKG, "PipelineGraph.Hop.Tooltip.HopTypeCopy", 
transform.getName(), Const.CR));
+            tipImage = GuiResource.getInstance().getImageCopyHop();
+          }
           break;
         case ROW_DISTRIBUTION_ICON:
-          transform = (TransformMeta) areaOwner.getParent();
-          tip.append(
-              BaseMessages.getString(
-                  PKG,
-                  "PipelineGraph.Hop.Tooltip.RowDistribution",
-                  transform.getName(),
-                  transform.getRowDistribution() == null
-                      ? ""
-                      : transform.getRowDistribution().getDescription()));
-          tip.append(Const.CR);
-          tipImage = GuiResource.getInstance().getImageBalance();
+          {
+            TransformMeta transform = (TransformMeta) areaOwner.getParent();
+            tip.append(
+                BaseMessages.getString(
+                    PKG,
+                    "PipelineGraph.Hop.Tooltip.RowDistribution",
+                    transform.getName(),
+                    transform.getRowDistribution() == null
+                        ? ""
+                        : transform.getRowDistribution().getDescription()));
+            tip.append(Const.CR);
+            tipImage = GuiResource.getInstance().getImageBalance();
+          }
           break;
         case HOP_INFO_ICON:
-          TransformMeta from = (TransformMeta) areaOwner.getParent();
-          TransformMeta to = (TransformMeta) areaOwner.getOwner();
-          tip.append(
-              BaseMessages.getString(
-                  PKG,
-                  "PipelineGraph.Hop.Tooltip.HopTypeInfo",
-                  to.getName(),
-                  from.getName(),
-                  Const.CR));
-          tipImage = GuiResource.getInstance().getImageInfo();
+          {
+            TransformMeta from = (TransformMeta) areaOwner.getParent();
+            TransformMeta to = (TransformMeta) areaOwner.getOwner();
+            tip.append(
+                BaseMessages.getString(
+                    PKG,
+                    "PipelineGraph.Hop.Tooltip.HopTypeInfo",
+                    to.getName(),
+                    from.getName(),
+                    Const.CR));
+            tipImage = GuiResource.getInstance().getImageInfo();
+          }
           break;
         case HOP_ERROR_ICON:
-          from = (TransformMeta) areaOwner.getParent();
-          to = (TransformMeta) areaOwner.getOwner();
-          areaOwner.getOwner();
-          tip.append(
-              BaseMessages.getString(
-                  PKG,
-                  "PipelineGraph.Hop.Tooltip.HopTypeError",
-                  from.getName(),
-                  to.getName(),
-                  Const.CR));
-          tipImage = GuiResource.getInstance().getImageError();
+          {
+            TransformMeta from = (TransformMeta) areaOwner.getParent();
+            TransformMeta to = (TransformMeta) areaOwner.getOwner();
+            tip.append(
+                BaseMessages.getString(
+                    PKG,
+                    "PipelineGraph.Hop.Tooltip.HopTypeError",
+                    from.getName(),
+                    to.getName(),
+                    Const.CR));
+            tipImage = GuiResource.getInstance().getImageError();
+          }
           break;
         case HOP_INFO_TRANSFORM_COPIES_ERROR:
-          from = (TransformMeta) areaOwner.getParent();
-          to = (TransformMeta) areaOwner.getOwner();
-          tip.append(
-              BaseMessages.getString(
-                  PKG,
-                  "PipelineGraph.Hop.Tooltip.InfoTransformCopies",
-                  from.getName(),
-                  to.getName(),
-                  Const.CR));
-          tipImage = GuiResource.getInstance().getImageError();
+          {
+            TransformMeta from = (TransformMeta) areaOwner.getParent();
+            TransformMeta to = (TransformMeta) areaOwner.getOwner();
+            tip.append(
+                BaseMessages.getString(
+                    PKG,
+                    "PipelineGraph.Hop.Tooltip.InfoTransformCopies",
+                    from.getName(),
+                    to.getName(),
+                    Const.CR));
+            tipImage = GuiResource.getInstance().getImageError();
+          }
           break;
         case HOP_INFO_TRANSFORMS_PARTITIONED:
-          from = (TransformMeta) areaOwner.getParent();
-          to = (TransformMeta) areaOwner.getOwner();
-          tip.append(
-              BaseMessages.getString(
-                  PKG,
-                  "PipelineGraph.Hop.Tooltip.InfoTransformsPartitioned",
-                  from.getName(),
-                  to.getName(),
-                  Const.CR));
-          tipImage = GuiResource.getInstance().getImageError();
+          {
+            TransformMeta from = (TransformMeta) areaOwner.getParent();
+            TransformMeta to = (TransformMeta) areaOwner.getOwner();
+            tip.append(
+                BaseMessages.getString(
+                    PKG,
+                    "PipelineGraph.Hop.Tooltip.InfoTransformsPartitioned",
+                    from.getName(),
+                    to.getName(),
+                    Const.CR));
+            tipImage = GuiResource.getInstance().getImageError();
+          }
           break;
 
         case TRANSFORM_TARGET_HOP_ICON:
@@ -4319,9 +4250,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
                     "PipelineGraph.DeprecatedTransform.Tooltip.Message1",
                     iconTransformMeta.getName());
             int length = tipNext.length() + 5;
-            for (int i = 0; i < length; i++) {
-              tip.append("-");
-            }
+            tip.repeat("-", Math.max(0, length));
             tip.append(Const.CR).append(tipNext).append(Const.CR);
             tip.append(
                 BaseMessages.getString(PKG, 
"PipelineGraph.DeprecatedTransform.Tooltip.Message2"));
@@ -4343,7 +4272,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
         case TRANSFORM_OUTPUT_DATA:
           RowBuffer rowBuffer = (RowBuffer) areaOwner.getOwner();
           if (rowBuffer != null && !rowBuffer.isEmpty()) {
-            tip.append("Available output rows: " + rowBuffer.size());
+            tip.append("Available output rows: ").append(rowBuffer.size());
             tipImage = GuiResource.getInstance().getImageData();
           }
           break;
@@ -4359,7 +4288,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
                   .append(hopMeta.getToTransform().getName())
                   .append(Const.CR);
             }
-            tip.append("Available output rows: " + hopRowBuffer.size());
+            tip.append("Available output rows: ").append(hopRowBuffer.size());
             tipImage = GuiResource.getInstance().getImageData();
           }
           break;
@@ -4435,8 +4364,6 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       }
       showSpecialTooltip(newTip, screenX, screenY);
     }
-
-    return subject;
   }
 
   public void showSpecialTooltip(String label, int screenX, int screenY) {
@@ -4532,7 +4459,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
             while (pmd.getShell() == null
                 || (!pmd.getShell().isDisposed() && !monitor.isCanceled())) {
               try {
-                Thread.sleep(250);
+                sleep(250);
               } catch (InterruptedException e) {
                 // Ignore
               }
@@ -4890,7 +4817,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     List<TransformMeta> selection = pipelineMeta.getSelectedTransforms();
     int[] indices = pipelineMeta.getTransformIndexes(selection);
 
-    return new SnapAllignDistribute(pipelineMeta, selection, indices, 
hopGui.undoDelegate, this);
+    return new SnapAllignDistribute(pipelineMeta, selection, indices, null, 
this);
   }
 
   @GuiToolbarElement(
@@ -4998,14 +4925,14 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return pipelineMeta.hasChanged();
   }
 
-  public boolean editProperties(PipelineMeta pipelineMeta, HopGui hopGui) {
-    return editProperties(pipelineMeta, hopGui, null);
+  public void editProperties(PipelineMeta pipelineMeta, HopGui hopGui) {
+    editProperties(pipelineMeta, hopGui, null);
   }
 
-  public boolean editProperties(
+  public void editProperties(
       PipelineMeta pipelineMeta, HopGui hopGui, PipelineDialog.Tabs 
currentTab) {
     if (pipelineMeta == null) {
-      return false;
+      return;
     }
 
     Shell shell = hopGui.getActiveShell();
@@ -5016,9 +4943,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     if (tid.open() != null) {
       hopGui.setParametersAsVariablesInUI(pipelineMeta, variables);
       updateGui();
-      return true;
     }
-    return false;
   }
 
   @Override
@@ -5054,6 +4979,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
         
out.write(XmlHandler.getXmlHeader(Const.UTF_8).getBytes(StandardCharsets.UTF_8));
         out.write(xml.getBytes(StandardCharsets.UTF_8));
         pipelineMeta.clearChanged();
+        rememberSavedSnapshot();
         updateGui();
       } finally {
         out.flush();
@@ -5564,8 +5490,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     if (extraViewTabFolder != null
         && !extraViewTabFolder.isDisposed()
         && extraViewTabFolder.getItemCount() > 0) {
-      extraViewTabFolder.setSelection(
-          Math.max(0, Math.min(index, extraViewTabFolder.getItemCount() - 1)));
+      extraViewTabFolder.setSelection(Math.clamp(index, 0, 
extraViewTabFolder.getItemCount() - 1));
     }
   }
 
@@ -6275,11 +6200,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       messageBox.open();
       return;
     }
-    if (pipeline.isFinished()) {
-      // Show collected sample data...
-      //
-
-    } else {
+    if (!pipeline.isFinished()) {
       try {
         pipeline.retrieveComponentOutput(
             hopGui.getVariables(),
@@ -6314,8 +6235,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   /**
    * Edit the transform of the given pipeline
    *
-   * @param pipelineMeta
-   * @param transformMeta
+   * @param pipelineMeta The pipeline metadata to reference
+   * @param transformMeta The transform metadata to edit
    */
   public void editTransform(PipelineMeta pipelineMeta, TransformMeta 
transformMeta) {
     pipelineTransformDelegate.editTransform(pipelineMeta, transformMeta);
@@ -6344,9 +6265,9 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
    *
    * <p>Prompt auto save feature...
    *
-   * @param pipelineMeta
+   * @param pipelineMeta The pipeline to handle changes for
    * @return true if pipeline meta has name and if changed is saved
-   * @throws HopException
+   * @throws HopException In case something goes wrong
    */
   public boolean handlePipelineMetaChanges(PipelineMeta pipelineMeta) throws 
HopException {
     if (pipelineMeta.hasChanged()) {
@@ -6508,6 +6429,69 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return Objects.hash(pipelineMeta, id);
   }
 
+  @Override
+  public boolean isUndoMeta(IUndo undoInterface) {
+    return undoInterface == pipelineMeta;
+  }
+
+  @Override
+  public void markUndoPoint() {
+    snapshotUndo.markUndoPoint();
+  }
+
+  @Override
+  public byte[] captureUndoSnapshot() {
+    return snapshotUndo.captureUndoSnapshot();
+  }
+
+  @Override
+  public void commitDialogUndo(byte[] before) {
+    snapshotUndo.commitDialogUndo(before);
+  }
+
+  @Override
+  public void recordAfterChange(boolean nextAlso) {
+    snapshotUndo.recordAfterChange(nextAlso);
+  }
+
+  @Override
+  public void markPositionUndoPoint() {
+    snapshotUndo.markPositionUndoPoint();
+  }
+
+  @Override
+  public void resetPositionUndoMark() {
+    snapshotUndo.resetPositionUndoMark();
+  }
+
+  @Override
+  public void rememberSavedSnapshot() {
+    snapshotUndo.rememberSavedSnapshot();
+  }
+
+  @Override
+  public boolean canUndo() {
+    return snapshotUndo.canUndo();
+  }
+
+  @Override
+  public boolean canRedo() {
+    return snapshotUndo.canRedo();
+  }
+
+  private void restoreAfterSnapshot() {
+    if (pipelineMeta != null) {
+      pipelineMeta.setInternalHopVariables(variables);
+    }
+    clearSettings();
+    snapshotUndo.resetPositionUndoMark();
+    if (pipelineGridDelegate != null) {
+      pipelineGridDelegate.onPipelineSelectionChanged();
+    }
+    updateGui();
+    redraw();
+  }
+
   @GuiToolbarElement(
       root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ITEM_UNDO_ID,
@@ -6519,7 +6503,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   @GuiOsxKeyboardShortcut(command = true, key = 'z')
   @Override
   public void undo() {
-    pipelineUndoDelegate.undoPipelineAction(this, pipelineMeta);
+    snapshotUndo.undo();
     forceFocus();
   }
 
@@ -6533,7 +6517,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'z')
   @Override
   public void redo() {
-    pipelineUndoDelegate.redoPipelineAction(this, pipelineMeta);
+    snapshotUndo.redo();
     forceFocus();
   }
 
@@ -6554,10 +6538,9 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
               // Enable/disable the undo/redo toolbar buttons...
               //
-              toolBarWidgets.enableToolbarItem(
-                  TOOLBAR_ITEM_UNDO_ID, pipelineMeta.viewThisUndo() != null);
-              toolBarWidgets.enableToolbarItem(
-                  TOOLBAR_ITEM_REDO_ID, pipelineMeta.viewNextUndo() != null);
+              snapshotUndo.refreshLastSnapshot();
+              toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_UNDO_ID, 
snapshotUndo.canUndo());
+              toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_REDO_ID, 
snapshotUndo.canRedo());
 
               // Enable/disable the execution toolbar buttons
               //
@@ -6581,7 +6564,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
               toolBarWidgets.enableToolbarItem(
                   TOOLBAR_ITEM_TO_EXECUTION_INFO, hasExecutionInfoLocations);
 
-              hopGui.setUndoMenu(pipelineMeta);
+              hopGui.setUndoMenu(snapshotUndo.canUndo(), 
snapshotUndo.canRedo());
               hopGui.handleFileCapabilities(fileType, 
pipelineMeta.hasChanged(), running, paused);
 
               // Enable the align/distribute menus if one or more transforms 
are selected.
@@ -6701,7 +6684,9 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       categoryOrder = "1")
   public void copyTransformToClipboard(HopGuiPipelineTransformContext context) 
{
     pipelineClipboardDelegate.copySelected(
-        pipelineMeta, Arrays.asList(context.getTransformMeta()), 
Collections.emptyList());
+        pipelineMeta,
+        Collections.singletonList(context.getTransformMeta()),
+        Collections.emptyList());
   }
 
   @GuiKeyboardShortcut(key = ' ')
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
index 8cff22ab73..ea9e97f205 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineClipboardDelegate.java
@@ -116,8 +116,7 @@ public class HopGuiPipelineClipboardDelegate {
         offset = new Point(-min.x, -min.y);
       }
 
-      // Undo/redo object positions...
-      int[] position = new int[transforms.size()];
+      pipelineGraph.markUndoPoint();
 
       for (int i = 0; i < transforms.size(); i++) {
         Point p = transforms.get(i).getLocation();
@@ -129,7 +128,6 @@ public class HopGuiPipelineClipboardDelegate {
         transformOldNames.add(name);
         
transforms.get(i).setName(pipelineMeta.getAlternativeTransformName(name));
         pipelineMeta.addTransform(transforms.get(i));
-        position[i] = pipelineMeta.indexOfTransform(transforms.get(i));
         transforms.get(i).setSelected(true);
       }
 
@@ -184,23 +182,7 @@ public class HopGuiPipelineClipboardDelegate {
         }
       }
 
-      // Save undo information too...
-      hopGui.undoDelegate.addUndoNew(
-          pipelineMeta, transforms.toArray(new TransformMeta[0]), position, 
false);
-
-      int[] hopPos = new int[hops.size()];
-      for (int i = 0; i < hops.size(); i++) {
-        hopPos[i] = pipelineMeta.indexOfPipelineHop(hops.get(i));
-      }
-      hopGui.undoDelegate.addUndoNew(
-          pipelineMeta, hops.toArray(new PipelineHopMeta[0]), hopPos, true);
-
-      int[] notePos = new int[notes.size()];
-      for (int i = 0; i < notes.size(); i++) {
-        notePos[i] = pipelineMeta.indexOfNote(notes.get(i));
-      }
-      hopGui.undoDelegate.addUndoNew(
-          pipelineMeta, notes.toArray(new NotePadMeta[0]), notePos, true);
+      // Undo was recorded once before adding the pasted objects.
     } catch (HopException e) {
       // See if this was different (non-XML) content
       //
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
index f17055e354..34cfb87777 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
@@ -199,6 +199,7 @@ public class HopGuiPipelineTransformDelegate {
 
       dialog = getTransformDialog(transformMeta.getTransform(), pipelineMeta, 
name);
       TransformMeta before = null;
+      byte[] beforeSnapshot = null;
       if (dialog != null) {
         dialogs.put(name, dialog);
 
@@ -206,6 +207,7 @@ public class HopGuiPipelineTransformDelegate {
         transformMeta.getTransform().convertIOMetaToTransformNames();
         // Snapshot after IO-meta normalization so OK-without-edits is not 
treated as a change.
         before = (TransformMeta) transformMeta.clone();
+        beforeSnapshot = pipelineGraph.captureUndoSnapshot();
         // Subject stack covers legacy dialogs that never set 
BaseDialog.DIALOG_SUBJECT
         transformName = 
BaseDialog.withDialogSubject(transformMeta.getTransform(), dialog::open);
 
@@ -261,13 +263,9 @@ public class HopGuiPipelineTransformDelegate {
         transformMeta.setName(transformName);
 
         TransformMeta after = (TransformMeta) transformMeta.clone();
+        pipelineGraph.commitDialogUndo(beforeSnapshot);
         if (hasTransformMetaChanged(before, after)) {
           transformMeta.setChanged();
-          hopGui.undoDelegate.addUndoChange(
-              pipelineMeta,
-              new TransformMeta[] {before},
-              new TransformMeta[] {after},
-              new int[] {pipelineMeta.indexOfTransform(transformMeta)});
         } else {
           transformMeta.setChanged(before.hasChanged());
         }
@@ -549,6 +547,7 @@ public class HopGuiPipelineTransformDelegate {
   }
 
   public void editTransformPartitioning(PipelineMeta pipelineMeta, 
TransformMeta transformMeta) {
+    byte[] beforeSnapshot = pipelineGraph.captureUndoSnapshot();
     String[] schemaNames;
     try {
       schemaNames = hopGui.partitionManager.getNamesArray();
@@ -607,17 +606,9 @@ public class HopGuiPipelineTransformDelegate {
 
         TransformMeta partitionBefore = partitionSettings.getBefore();
         TransformMeta partitionAfter = partitionSettings.getAfter();
+        pipelineGraph.commitDialogUndo(beforeSnapshot);
         if (hasTransformMetaChanged(partitionBefore, partitionAfter)) {
           transformMeta.setChanged();
-          hopGui.undoDelegate.addUndoChange(
-              partitionSettings.getPipelineMeta(),
-              new TransformMeta[] {partitionBefore},
-              new TransformMeta[] {partitionAfter},
-              new int[] {
-                partitionSettings
-                    .getPipelineMeta()
-                    .indexOfTransform(partitionSettings.getTransformMeta())
-              });
         } else {
           transformMeta.setChanged(partitionBefore.hasChanged());
         }
@@ -686,6 +677,7 @@ public class HopGuiPipelineTransformDelegate {
 
       // now edit this transformErrorMeta object:
       TransformMeta before = (TransformMeta) transformMeta.clone();
+      byte[] beforeSnapshot = pipelineGraph.captureUndoSnapshot();
       TransformErrorMetaDialog dialog =
           new TransformErrorMetaDialog(
               hopGui.getActiveShell(),
@@ -697,6 +689,7 @@ public class HopGuiPipelineTransformDelegate {
           BaseDialog.withDialogSubject(transformMeta.getTransform(), 
dialog::open))) {
         transformMeta.setTransformErrorMeta(transformErrorMeta);
         TransformMeta after = (TransformMeta) transformMeta.clone();
+        pipelineGraph.commitDialogUndo(beforeSnapshot);
         if (hasTransformMetaChanged(before, after)) {
           transformMeta.setChanged();
         } else {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineUndoDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineUndoDelegate.java
index 8876ff0d3e..48a71e060c 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineUndoDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineUndoDelegate.java
@@ -17,339 +17,45 @@
 
 package org.apache.hop.ui.hopgui.file.pipeline.delegates;
 
-import org.apache.hop.core.NotePadMeta;
-import org.apache.hop.core.gui.Point;
-import org.apache.hop.core.undo.ChangeAction;
-import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
-import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
 
+/**
+ * Pipeline undo/redo is implemented with gzip XML snapshots on {@link 
HopGuiPipelineGraph}. This
+ * class remains as a hook for any leftover callers.
+ */
 public class HopGuiPipelineUndoDelegate {
 
   private HopGuiPipelineGraph pipelineGraph;
   private HopGui hopGui;
 
-  /**
-   * @param hopGui
-   */
   public HopGuiPipelineUndoDelegate(HopGui hopGui, HopGuiPipelineGraph 
pipelineGraph) {
     this.hopGui = hopGui;
     this.pipelineGraph = pipelineGraph;
   }
 
   public void undoPipelineAction(IHopFileTypeHandler handler, PipelineMeta 
pipelineMeta) {
-    ChangeAction changeAction = pipelineMeta.previousUndo();
-    if (changeAction == null) {
-      return;
-    }
-    undoPipelineAction(handler, pipelineMeta, changeAction);
-    handler.updateGui();
-  }
-
-  public void undoPipelineAction(
-      IHopFileTypeHandler handler, PipelineMeta pipelineMeta, ChangeAction 
changeAction) {
-    switch (changeAction.getType()) {
-        // We created a new transform : undo this...
-      case NewTransform:
-        // Delete the transform at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removeTransform(idx);
-        }
-        break;
-
-        // We created a new note : undo this...
-      case NewNote:
-        // Delete the note at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removeNote(idx);
-        }
-        break;
-
-        // We created a new hop : undo this...
-      case NewPipelineHop:
-        // Delete the hop at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removePipelineHop(idx);
-        }
-        break;
-
-        //
-        // DELETE
-        //
-
-        // We delete a transform : undo this...
-      case DeleteTransform:
-        // un-Delete the transform at correct location: re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          TransformMeta transformMeta = (TransformMeta) 
changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.addTransform(idx, transformMeta);
-        }
-        break;
-
-        // We delete new note : undo this...
-      case DeleteNote:
-        // re-insert the note at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          NotePadMeta ni = (NotePadMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.addNote(idx, ni);
-        }
-        break;
-
-        // We deleted a hop : undo this...
-      case DeletePipelineHop:
-        // re-insert the hop at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          PipelineHopMeta hi = (PipelineHopMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          // Build a new hop:
-          TransformMeta from = 
pipelineMeta.findTransform(hi.getFromTransform().getName());
-          TransformMeta to = 
pipelineMeta.findTransform(hi.getToTransform().getName());
-          PipelineHopMeta hinew = new PipelineHopMeta(from, to);
-          pipelineMeta.addPipelineHop(idx, hinew);
-        }
-        break;
-
-        //
-        // CHANGE
-        //
-
-        // We changed a transform : undo this...
-      case ChangeTransform:
-        // Delete the current transform, insert previous version.
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          TransformMeta prev =
-              (TransformMeta) ((TransformMeta) 
changeAction.getPrevious()[i]).clone();
-          int idx = changeAction.getCurrentIndex()[i];
-
-          pipelineMeta.getTransform(idx).replaceMeta(prev);
-        }
-        break;
-
-        // We changed a note : undo this...
-      case ChangeNote:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removeNote(idx);
-          NotePadMeta prev = (NotePadMeta) changeAction.getPrevious()[i];
-          pipelineMeta.addNote(idx, (NotePadMeta) prev.clone());
-        }
-        break;
-
-        // We changed a hop : undo this...
-      case ChangePipelineHop:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          PipelineHopMeta prev = (PipelineHopMeta) 
changeAction.getPrevious()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-
-          pipelineMeta.removePipelineHop(idx);
-          pipelineMeta.addPipelineHop(idx, (PipelineHopMeta) prev.clone());
-        }
-        break;
-
-        //
-        // POSITION
-        //
-
-        // The position of a transform has changed: undo this...
-      case PositionTransform:
-        // Find the location of the transform:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          TransformMeta transformMeta =
-              pipelineMeta.getTransform(changeAction.getCurrentIndex()[i]);
-          transformMeta.setLocation(changeAction.getPreviousLocation()[i]);
-        }
-        break;
-
-        // The position of a note has changed: undo this...
-      case PositionNote:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          int idx = changeAction.getCurrentIndex()[i];
-          NotePadMeta npi = pipelineMeta.getNote(idx);
-          Point prev = changeAction.getPreviousLocation()[i];
-          npi.setLocation(prev);
-        }
-        break;
-      default:
-        break;
-    }
-
-    // OK, now check if we need to do this again...
-    if (pipelineMeta.viewNextUndo() != null && 
pipelineMeta.viewNextUndo().getNextAlso()) {
-      undoPipelineAction(handler, pipelineMeta);
-    }
+    pipelineGraph.undo();
   }
 
   public void redoPipelineAction(IHopFileTypeHandler handler, PipelineMeta 
pipelineMeta) {
-    ChangeAction changeAction = pipelineMeta.nextUndo();
-    if (changeAction == null) {
-      return;
-    }
-    redoPipelineAction(handler, pipelineMeta, changeAction);
-    handler.updateGui();
-  }
-
-  public void redoPipelineAction(
-      IHopFileTypeHandler handler, PipelineMeta pipelineMeta, ChangeAction 
changeAction) {
-    switch (changeAction.getType()) {
-      case NewTransform:
-        // re-delete the transform at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          TransformMeta transformMeta = (TransformMeta) 
changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.addTransform(idx, transformMeta);
-        }
-        break;
-
-      case NewNote:
-        // re-insert the note at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          NotePadMeta ni = (NotePadMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.addNote(idx, ni);
-        }
-        break;
-
-      case NewPipelineHop:
-        // re-insert the hop at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          PipelineHopMeta hi = (PipelineHopMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.addPipelineHop(idx, hi);
-        }
-        break;
-
-        //
-        // DELETE
-        //
-      case DeleteTransform:
-        // re-remove the transform at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removeTransform(idx);
-        }
-        break;
-
-      case DeleteNote:
-        // re-remove the note at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removeNote(idx);
-        }
-        break;
-
-      case DeletePipelineHop:
-        // re-remove the hop at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          pipelineMeta.removePipelineHop(idx);
-        }
-        break;
-
-        //
-        // CHANGE
-        //
-
-        // We changed a transform : undo this...
-      case ChangeTransform:
-        // Delete the current transform, insert previous version.
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          TransformMeta transformMeta =
-              (TransformMeta) ((TransformMeta) 
changeAction.getCurrent()[i]).clone();
-          
pipelineMeta.getTransform(changeAction.getCurrentIndex()[i]).replaceMeta(transformMeta);
-        }
-        break;
-
-        // We changed a note : undo this...
-      case ChangeNote:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          NotePadMeta ni = (NotePadMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-
-          pipelineMeta.removeNote(idx);
-          pipelineMeta.addNote(idx, (NotePadMeta) ni.clone());
-        }
-        break;
-
-        // We changed a hop : undo this...
-      case ChangePipelineHop:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          PipelineHopMeta hi = (PipelineHopMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-
-          pipelineMeta.removePipelineHop(idx);
-          pipelineMeta.addPipelineHop(idx, (PipelineHopMeta) hi.clone());
-        }
-        break;
-
-        //
-        // CHANGE POSITION
-        //
-      case PositionTransform:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          // Find & change the location of the transform:
-          TransformMeta transformMeta =
-              pipelineMeta.getTransform(changeAction.getCurrentIndex()[i]);
-          transformMeta.setLocation(changeAction.getCurrentLocation()[i]);
-        }
-        break;
-      case PositionNote:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          int idx = changeAction.getCurrentIndex()[i];
-          NotePadMeta npi = pipelineMeta.getNote(idx);
-          Point curr = changeAction.getCurrentLocation()[i];
-          npi.setLocation(curr);
-        }
-        break;
-      default:
-        break;
-    }
-
-    // OK, now check if we need to do this again...
-    if (pipelineMeta.viewNextUndo() != null && 
pipelineMeta.viewNextUndo().getNextAlso()) {
-      redoPipelineAction(handler, pipelineMeta);
-    }
+    pipelineGraph.redo();
   }
 
-  /**
-   * Gets pipelineGraph
-   *
-   * @return value of pipelineGraph
-   */
   public HopGuiPipelineGraph getPipelineGraph() {
     return pipelineGraph;
   }
 
-  /**
-   * @param pipelineGraph The pipelineGraph to set
-   */
   public void setPipelineGraph(HopGuiPipelineGraph pipelineGraph) {
     this.pipelineGraph = pipelineGraph;
   }
 
-  /**
-   * Gets hopGui
-   *
-   * @return value of hopGui
-   */
   public HopGui getHopGui() {
     return hopGui;
   }
 
-  /**
-   * @param hopGui The hopGui to set
-   */
   public void setHopGui(HopGui hopGui) {
     this.hopGui = hopGui;
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
index a7ddd87a10..878fa7c348 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
@@ -179,32 +179,42 @@ public abstract class HopGuiAbstractGraph extends 
DragViewZoomBase
 
   public abstract SnapAllignDistribute createSnapAlignDistribute();
 
+  /** Record an undo snapshot before a structural change. Overridden by 
pipeline/workflow graphs. */
+  protected void markUndoPoint() {
+    // no-op for graphs that do not implement snapshot undo
+  }
+
   @Override
   public void snapToGrid() {
     snapToGrid(ConstUi.GRID_SIZE);
   }
 
   private void snapToGrid(int size) {
+    markUndoPoint();
     createSnapAlignDistribute().snapToGrid(size);
     setChanged();
   }
 
   public void alignLeft() {
+    markUndoPoint();
     createSnapAlignDistribute().allignleft();
     setChanged();
   }
 
   public void alignRight() {
+    markUndoPoint();
     createSnapAlignDistribute().allignright();
     setChanged();
   }
 
   public void alignTop() {
+    markUndoPoint();
     createSnapAlignDistribute().alligntop();
     setChanged();
   }
 
   public void alignBottom() {
+    markUndoPoint();
     createSnapAlignDistribute().allignbottom();
     setChanged();
   }
@@ -212,12 +222,14 @@ public abstract class HopGuiAbstractGraph extends 
DragViewZoomBase
   @GuiKeyboardShortcut(alt = true, key = SWT.ARROW_RIGHT)
   @GuiOsxKeyboardShortcut(alt = true, key = SWT.ARROW_RIGHT)
   public void distributeHorizontal() {
+    markUndoPoint();
     createSnapAlignDistribute().distributehorizontal();
     setChanged();
   }
 
   @GuiOsxKeyboardShortcut(alt = true, key = SWT.ARROW_UP)
   public void distributeVertical() {
+    markUndoPoint();
     createSnapAlignDistribute().distributevertical();
     setChanged();
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiGraphSnapshotUndo.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiGraphSnapshotUndo.java
new file mode 100644
index 0000000000..73dcdebf15
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiGraphSnapshotUndo.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ui.hopgui.file.shared;
+
+import java.util.function.Supplier;
+import org.apache.hop.base.AbstractMeta;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.undo.XmlSnapshotUndo;
+import org.apache.hop.core.undo.XmlSnapshotUndo.ContentRestorer;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.hopgui.HopGui;
+
+/**
+ * Graph-owned gzip XML undo/redo stacks, ported from hop-data-vault {@code 
ModelGraphSnapshotUndo}.
+ */
+public class HopGuiGraphSnapshotUndo<M extends AbstractMeta> {
+
+  private static final Class<?> PKG = HopGui.class;
+
+  private final HopGui hopGui;
+  private final XmlSnapshotUndo<M> engine;
+  private final Supplier<M> metaSupplier;
+  private final Supplier<String> filenameSupplier;
+  private final Runnable afterRestore;
+
+  private byte[] lastSnapshot;
+  private byte[] lastSavedSnapshot;
+  private boolean positionChangeUndoMarked;
+
+  public HopGuiGraphSnapshotUndo(
+      HopGui hopGui,
+      Class<M> type,
+      String xmlRootTag,
+      ContentRestorer<M> restorer,
+      Supplier<M> metaSupplier,
+      Supplier<String> filenameSupplier,
+      Runnable afterRestore) {
+    this.hopGui = hopGui;
+    this.engine =
+        new XmlSnapshotUndo<>(type, xmlRootTag, restorer, () -> 
PropsUi.getInstance().getMaxUndo());
+    this.metaSupplier = metaSupplier;
+    this.filenameSupplier = filenameSupplier;
+    this.afterRestore = afterRestore;
+  }
+
+  public void initialize() {
+    lastSnapshot = captureQuiet();
+    lastSavedSnapshot = lastSnapshot;
+  }
+
+  public void rememberSavedSnapshot() {
+    lastSavedSnapshot = captureQuiet();
+    lastSnapshot = lastSavedSnapshot;
+  }
+
+  public void refreshLastSnapshot() {
+    if (engine.isApplyingSnapshot()) {
+      return;
+    }
+    lastSnapshot = captureQuiet();
+  }
+
+  public boolean canUndo() {
+    return engine.canUndo();
+  }
+
+  public boolean canRedo() {
+    return engine.canRedo();
+  }
+
+  public boolean isApplyingSnapshot() {
+    return engine.isApplyingSnapshot();
+  }
+
+  public void markUndoPoint() {
+    M model = metaSupplier.get();
+    if (model == null || engine.isApplyingSnapshot()) {
+      return;
+    }
+    try {
+      engine.markChange(model, metadataProvider());
+    } catch (HopException e) {
+      showRecordError(e);
+    }
+  }
+
+  public byte[] captureUndoSnapshot() {
+    return captureQuiet();
+  }
+
+  public void commitDialogUndo(byte[] beforeChange) {
+    if (beforeChange == null || engine.isApplyingSnapshot()) {
+      return;
+    }
+    byte[] after = captureQuiet();
+    if (after == null || XmlSnapshotUndo.sameXmlContent(beforeChange, after)) {
+      return;
+    }
+    engine.pushSnapshot(beforeChange);
+    lastSnapshot = after;
+  }
+
+  /**
+   * Compatibility hook for leftover {@code addUndo*} calls that fire after 
the mutation. Pushes
+   * {@code lastSnapshot} (the pre-change document) unless {@code nextAlso} 
indicates a chained
+   * follow-up of the same user action.
+   */
+  public void recordAfterChange(boolean nextAlso) {
+    if (engine.isApplyingSnapshot()) {
+      return;
+    }
+    if (!nextAlso && lastSnapshot != null) {
+      engine.pushSnapshot(lastSnapshot);
+    }
+    lastSnapshot = captureQuiet();
+  }
+
+  public void markPositionUndoPoint() {
+    if (!positionChangeUndoMarked) {
+      markUndoPoint();
+      positionChangeUndoMarked = true;
+    }
+  }
+
+  public void resetPositionUndoMark() {
+    positionChangeUndoMarked = false;
+  }
+
+  public void undo() {
+    apply(true);
+  }
+
+  public void redo() {
+    apply(false);
+  }
+
+  private void apply(boolean isUndo) {
+    M model = metaSupplier.get();
+    if (model == null) {
+      return;
+    }
+    try {
+      boolean applied =
+          isUndo
+              ? engine.undo(model, metadataProvider(), filenameSupplier.get())
+              : engine.redo(model, metadataProvider(), filenameSupplier.get());
+      if (!applied) {
+        return;
+      }
+      lastSnapshot = captureQuiet();
+      applyDirtyFlag(model);
+      if (afterRestore != null) {
+        afterRestore.run();
+      }
+    } catch (HopException e) {
+      showApplyError(e);
+    }
+  }
+
+  private void applyDirtyFlag(M model) {
+    if (lastSavedSnapshot != null
+        && XmlSnapshotUndo.sameXmlContent(lastSnapshot, lastSavedSnapshot)) {
+      model.clearChanged();
+    } else {
+      model.setChanged();
+    }
+  }
+
+  private byte[] captureQuiet() {
+    M model = metaSupplier.get();
+    if (model == null || engine.isApplyingSnapshot()) {
+      return null;
+    }
+    try {
+      return engine.captureSnapshot(model, metadataProvider());
+    } catch (HopException e) {
+      showRecordError(e);
+      return null;
+    }
+  }
+
+  private IHopMetadataProvider metadataProvider() {
+    return hopGui.getMetadataProvider();
+  }
+
+  private void showRecordError(Exception e) {
+    new ErrorDialog(
+        hopGui.getShell(),
+        BaseMessages.getString(PKG, "HopGui.Undo.Error.Record.Title"),
+        BaseMessages.getString(PKG, "HopGui.Undo.Error.Record.Message"),
+        e);
+  }
+
+  private void showApplyError(Exception e) {
+    new ErrorDialog(
+        hopGui.getShell(),
+        BaseMessages.getString(PKG, "HopGui.Undo.Error.Apply.Title"),
+        BaseMessages.getString(PKG, "HopGui.Undo.Error.Apply.Message"),
+        e);
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/ISnapshotUndoSupport.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/ISnapshotUndoSupport.java
new file mode 100644
index 0000000000..3d355616dd
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/ISnapshotUndoSupport.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ui.hopgui.file.shared;
+
+import org.apache.hop.core.gui.IUndo;
+
+/**
+ * Snapshot-based undo/redo for pipeline and workflow graphs. {@code addUndo*} 
call sites that still
+ * fire after a mutation are routed through {@link 
#recordAfterChange(boolean)}.
+ */
+public interface ISnapshotUndoSupport {
+
+  boolean isUndoMeta(IUndo undoInterface);
+
+  void markUndoPoint();
+
+  byte[] captureUndoSnapshot();
+
+  void commitDialogUndo(byte[] before);
+
+  void recordAfterChange(boolean nextAlso);
+
+  void markPositionUndoPoint();
+
+  void resetPositionUndoMark();
+
+  void rememberSavedSnapshot();
+
+  boolean canUndo();
+
+  boolean canRedo();
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 5d96943c7b..8b26c9e414 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -36,7 +36,6 @@ import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileName;
 import org.apache.commons.vfs2.FileObject;
-import org.apache.hop.base.AbstractMeta;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.IEngineMeta;
 import org.apache.hop.core.NotePadMeta;
@@ -57,6 +56,7 @@ import org.apache.hop.core.gui.CanvasSvgRenderResult;
 import org.apache.hop.core.gui.DPoint;
 import org.apache.hop.core.gui.IGc;
 import org.apache.hop.core.gui.IRedrawable;
+import org.apache.hop.core.gui.IUndo;
 import org.apache.hop.core.gui.Point;
 import org.apache.hop.core.gui.Rectangle;
 import org.apache.hop.core.gui.SnapAllignDistribute;
@@ -134,7 +134,9 @@ import 
org.apache.hop.ui.hopgui.file.delegates.HopGuiNoteLinkSupport;
 import org.apache.hop.ui.hopgui.file.delegates.HopGuiNotePadDelegate;
 import org.apache.hop.ui.hopgui.file.shared.DrillDownGuiPlugin;
 import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
+import org.apache.hop.ui.hopgui.file.shared.HopGuiGraphSnapshotUndo;
 import org.apache.hop.ui.hopgui.file.shared.HopGuiTooltipExtension;
+import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
 import 
org.apache.hop.ui.hopgui.file.workflow.context.HopGuiWorkflowActionContext;
 import org.apache.hop.ui.hopgui.file.workflow.context.HopGuiWorkflowContext;
 import org.apache.hop.ui.hopgui.file.workflow.context.HopGuiWorkflowHopContext;
@@ -217,7 +219,8 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
         ILogParentProvided,
         IHopFileTypeHandler,
         IGuiRefresher,
-        IWebCanvasGraph {
+        IWebCanvasGraph,
+        ISnapshotUndoSupport {
 
   private static final Class<?> PKG = HopGuiWorkflowGraph.class;
 
@@ -386,6 +389,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
   public HopGuiWorkflowClipboardDelegate workflowClipboardDelegate;
   public HopGuiWorkflowRunDelegate workflowRunDelegate;
   public HopGuiWorkflowUndoDelegate workflowUndoDelegate;
+  private final HopGuiGraphSnapshotUndo<WorkflowMeta> snapshotUndo;
   public HopGuiWorkflowActionDelegate workflowActionDelegate;
   public HopGuiWorkflowHopDelegate workflowHopDelegate;
   public HopGuiNotePadDelegate notePadDelegate;
@@ -442,6 +446,17 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     workflowClipboardDelegate = new HopGuiWorkflowClipboardDelegate(hopGui, 
this);
     workflowRunDelegate = new HopGuiWorkflowRunDelegate(hopGui, this);
     workflowUndoDelegate = new HopGuiWorkflowUndoDelegate(hopGui, this);
+    snapshotUndo =
+        new HopGuiGraphSnapshotUndo<>(
+            hopGui,
+            WorkflowMeta.class,
+            WorkflowMeta.XML_TAG,
+            (target, node, provider, filename) ->
+                target.restoreContentFromXml(node, filename, provider),
+            () -> this.workflowMeta,
+            this::getFilename,
+            this::restoreAfterSnapshot);
+    snapshotUndo.initialize();
     workflowActionDelegate = new HopGuiWorkflowActionDelegate(hopGui, this);
     workflowHopDelegate = new HopGuiWorkflowHopDelegate(hopGui, this);
     notePadDelegate = new HopGuiNotePadDelegate(hopGui, this);
@@ -796,6 +811,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
             // SWT keeps the threshold behaviour to distinguish a click from a 
drag.
             if (EnvironmentUtils.getInstance().isWeb() && event.button == 1 && 
!shift && !control) {
               actionDragCommitted = true;
+              markPositionUndoPoint();
               dragSelection = true;
               canvas.setData("mode", "drag");
               selectedActions = workflowMeta.getSelectedActions();
@@ -1161,31 +1177,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
           // Find out which Transforms & Notes are selected
           selectedActions = workflowMeta.getSelectedActions();
           selectedNotes = workflowMeta.getSelectedNotes();
-          // We moved around some items: store undo info...
-          //
-          boolean also = false;
-          if (!Utils.isEmpty(selectedNotes) && previousNoteLocations != null) {
-            int[] indexes = workflowMeta.getNoteIndexes(selectedNotes);
-
-            addUndoPosition(
-                selectedNotes.toArray(new NotePadMeta[selectedNotes.size()]),
-                indexes,
-                previousNoteLocations,
-                workflowMeta.getSelectedNoteLocations(),
-                also);
-            also = !Utils.isEmpty(selectedActions);
-          }
-          if (selectedActions != null
-              && !selectedActions.isEmpty()
-              && previousActionLocations != null) {
-            int[] indexes = workflowMeta.getActionIndexes(selectedActions);
-            addUndoPosition(
-                selectedActions.toArray(new 
ActionMeta[selectedActions.size()]),
-                indexes,
-                previousActionLocations,
-                workflowMeta.getSelectedLocations(),
-                also);
-          }
+          // Position undo was recorded at drag start via 
markPositionUndoPoint().
         }
       }
 
@@ -1231,6 +1223,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       endHopLocation = null;
       actionDragStartScreen = null;
       actionDragCommitted = false;
+      resetPositionUndoMark();
       removePlacementDragFilters();
 
       updateGui();
@@ -1264,31 +1257,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
             // Track that actions/notes were selected
             if (!selectedActions.isEmpty() || !selectedNotes.isEmpty()) {}
 
-            // We moved around some items: store undo info...
-            boolean also = false;
-            if (selectedNotes != null
-                && !selectedNotes.isEmpty()
-                && previousNoteLocations != null) {
-              int[] indexes = workflowMeta.getNoteIndexes(selectedNotes);
-              addUndoPosition(
-                  selectedNotes.toArray(new NotePadMeta[selectedNotes.size()]),
-                  indexes,
-                  previousNoteLocations,
-                  workflowMeta.getSelectedNoteLocations(),
-                  also);
-              also = !Utils.isEmpty(selectedActions);
-            }
-            if (selectedActions != null
-                && !selectedActions.isEmpty()
-                && previousActionLocations != null) {
-              int[] indexes = workflowMeta.getActionIndexes(selectedActions);
-              addUndoPosition(
-                  selectedActions.toArray(new 
ActionMeta[selectedActions.size()]),
-                  indexes,
-                  previousActionLocations,
-                  workflowMeta.getSelectedLocations(),
-                  also);
-            }
+            // Position undo was recorded at drag start via 
markPositionUndoPoint().
           }
         }
 
@@ -1956,6 +1925,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       int thresholdSq = ACTION_DRAG_THRESHOLD_PX * ACTION_DRAG_THRESHOLD_PX;
       if (dx * dx + dy * dy > thresholdSq) {
         actionDragCommitted = true;
+        markPositionUndoPoint();
         canvas.setData("mode", "drag");
         dragSelection = true;
         selectedActions = workflowMeta.getSelectedActions();
@@ -2095,6 +2065,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
        *
        * new : new position of the note (not the mouse pointer) dx : 
difference with previous position
        */
+      markPositionUndoPoint();
       int dx = note.x - selectedNote.getLocation().x;
       int dy = note.y - selectedNote.getLocation().y;
 
@@ -2311,48 +2282,18 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
    */
   private void applyAutoLayout(boolean selectionOnly) {
     List<ActionMeta> subset = null;
-    List<ActionMeta> moving;
     if (selectionOnly) {
       subset = workflowMeta.getSelectedActions();
       if (subset == null || subset.size() < 2) {
         return; // Nothing meaningful to arrange.
       }
-      moving = new ArrayList<>(subset);
-    } else {
-      int n = workflowMeta.nrActions();
-      if (n == 0) {
-        return;
-      }
-      moving = new ArrayList<>(n);
-      for (int i = 0; i < n; i++) {
-        moving.add(workflowMeta.getAction(i));
-      }
+    } else if (workflowMeta.nrActions() == 0) {
+      return;
     }
 
-    // Auto-layout may also reposition notes; capture them so the whole thing 
is one undo step.
-    List<NotePadMeta> notes = new ArrayList<>(workflowMeta.getNotes());
-    Point[] notesBefore = captureNoteLocations(notes);
-
-    Point[] before = captureLocations(moving);
+    byte[] beforeSnapshot = captureUndoSnapshot();
     WorkflowMetaLayout.layout(workflowMeta, 
PropsUi.getInstance().getAutoLayoutOptions(), subset);
-    Point[] after = captureLocations(moving);
-    Point[] notesAfter = captureNoteLocations(notes);
-
-    // Record notes first, then actions, linked into a single undo action 
(nextAlso).
-    boolean also = false;
-    if (!notes.isEmpty()) {
-      also = true;
-      hopGui.undoDelegate.addUndoPosition(
-          workflowMeta,
-          notes.toArray(new NotePadMeta[0]),
-          workflowMeta.getNoteIndexes(notes),
-          notesBefore,
-          notesAfter,
-          also);
-    }
-    int[] indexes = workflowMeta.getActionIndexes(moving);
-    hopGui.undoDelegate.addUndoPosition(
-        workflowMeta, moving.toArray(new ActionMeta[0]), indexes, before, 
after, also);
+    commitDialogUndo(beforeSnapshot);
 
     workflowMeta.setChanged();
     updateGui();
@@ -3637,6 +3578,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
     Point[] actionsBefore = captureLocations(actions);
     Point[] notesBefore = captureNoteLocations(notes);
+    byte[] beforeSnapshot = captureUndoSnapshot();
 
     moveSelected(dx, dy);
 
@@ -3647,26 +3589,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       return true;
     }
 
-    // Record notes first, then actions, linked into a single undo action 
(nextAlso).
-    boolean also = false;
-    if (!Utils.isEmpty(notes)) {
-      also = !Utils.isEmpty(actions);
-      addUndoPosition(
-          notes.toArray(new NotePadMeta[0]),
-          workflowMeta.getNoteIndexes(notes),
-          notesBefore,
-          notesAfter,
-          also);
-    }
-    if (!Utils.isEmpty(actions)) {
-      addUndoPosition(
-          actions.toArray(new ActionMeta[0]),
-          workflowMeta.getActionIndexes(actions),
-          actionsBefore,
-          actionsAfter,
-          also);
-    }
-
+    commitDialogUndo(beforeSnapshot);
     workflowMeta.setChanged();
     updateGui();
     return true;
@@ -4271,7 +4194,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
   public SnapAllignDistribute createSnapAlignDistribute() {
     List<ActionMeta> elements = workflowMeta.getSelectedActions();
     int[] indices = workflowMeta.getActionIndexes(elements);
-    return new SnapAllignDistribute(workflowMeta, elements, indices, 
hopGui.undoDelegate, this);
+    return new SnapAllignDistribute(workflowMeta, elements, indices, null, 
this);
   }
 
   @GuiContextAction(
@@ -4350,6 +4273,66 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
   }
 
+  @Override
+  public boolean isUndoMeta(IUndo undoInterface) {
+    return undoInterface == workflowMeta;
+  }
+
+  @Override
+  public void markUndoPoint() {
+    snapshotUndo.markUndoPoint();
+  }
+
+  @Override
+  public byte[] captureUndoSnapshot() {
+    return snapshotUndo.captureUndoSnapshot();
+  }
+
+  @Override
+  public void commitDialogUndo(byte[] before) {
+    snapshotUndo.commitDialogUndo(before);
+  }
+
+  @Override
+  public void recordAfterChange(boolean nextAlso) {
+    snapshotUndo.recordAfterChange(nextAlso);
+  }
+
+  @Override
+  public void markPositionUndoPoint() {
+    snapshotUndo.markPositionUndoPoint();
+  }
+
+  @Override
+  public void resetPositionUndoMark() {
+    snapshotUndo.resetPositionUndoMark();
+  }
+
+  @Override
+  public void rememberSavedSnapshot() {
+    snapshotUndo.rememberSavedSnapshot();
+  }
+
+  @Override
+  public boolean canUndo() {
+    return snapshotUndo.canUndo();
+  }
+
+  @Override
+  public boolean canRedo() {
+    return snapshotUndo.canRedo();
+  }
+
+  private void restoreAfterSnapshot() {
+    if (workflowMeta != null) {
+      workflowMeta.setInternalHopVariables(variables);
+    }
+    clearSettings();
+    snapshotUndo.resetPositionUndoMark();
+    updateGui();
+    redraw();
+  }
+
   @GuiToolbarElement(
       root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ITEM_UNDO_ID,
@@ -4361,7 +4344,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
   @GuiOsxKeyboardShortcut(command = true, key = 'z')
   @Override
   public void undo() {
-    workflowUndoDelegate.undoWorkflowAction(this, workflowMeta);
+    snapshotUndo.undo();
     forceFocus();
   }
 
@@ -4375,7 +4358,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'z')
   @Override
   public void redo() {
-    workflowUndoDelegate.redoWorkflowAction(this, workflowMeta);
+    snapshotUndo.redo();
     forceFocus();
   }
 
@@ -4413,10 +4396,9 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
               // Enable/disable the undo/redo toolbar buttons...
               //
-              toolBarWidgets.enableToolbarItem(
-                  TOOLBAR_ITEM_UNDO_ID, workflowMeta.viewThisUndo() != null);
-              toolBarWidgets.enableToolbarItem(
-                  TOOLBAR_ITEM_REDO_ID, workflowMeta.viewNextUndo() != null);
+              snapshotUndo.refreshLastSnapshot();
+              toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_UNDO_ID, 
snapshotUndo.canUndo());
+              toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_REDO_ID, 
snapshotUndo.canRedo());
 
               // Enable/disable the execution toolbar buttons
               //
@@ -4438,7 +4420,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
               toolBarWidgets.enableToolbarItem(
                   TOOLBAR_ITEM_TO_EXECUTION_INFO, hasExecutionInfoLocations);
 
-              hopGui.setUndoMenu(workflowMeta);
+              hopGui.setUndoMenu(snapshotUndo.canUndo(), 
snapshotUndo.canRedo());
               hopGui.handleFileCapabilities(fileType, 
workflowMeta.hasChanged(), running, false);
 
               // Enable the align/distribute toolbar menus if one or more 
actions are selected.
@@ -4533,6 +4515,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
         
out.write(XmlHandler.getXmlHeader(Const.UTF_8).getBytes(StandardCharsets.UTF_8));
         out.write(xml.getBytes(StandardCharsets.UTF_8));
         workflowMeta.clearChanged();
+        rememberSavedSnapshot();
         updateGui();
       } finally {
         out.flush();
@@ -5231,17 +5214,14 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     return () -> getWorkflow() != null ? getWorkflow().getLogChannel() : 
LogChannel.GENERAL;
   }
 
-  // Change of transform, connection, hop or note...
   public void addUndoPosition(Object[] obj, int[] pos, Point[] prev, Point[] 
curr) {
     addUndoPosition(obj, pos, prev, curr, false);
   }
 
-  // Change of transform, connection, hop or note...
   public void addUndoPosition(
       Object[] obj, int[] pos, Point[] prev, Point[] curr, boolean nextAlso) {
-    // It's better to store the indexes of the objects, not the objects itself!
-    workflowMeta.addUndo(obj, null, pos, prev, curr, 
AbstractMeta.TYPE_UNDO_POSITION, nextAlso);
-    hopGui.setUndoMenu(workflowMeta);
+    recordAfterChange(nextAlso);
+    hopGui.setUndoMenu(canUndo(), canRedo());
   }
 
   /**
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowActionDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowActionDelegate.java
index 22247f2081..3d854252a4 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowActionDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowActionDelegate.java
@@ -291,7 +291,7 @@ public class HopGuiWorkflowActionDelegate {
         return;
       }
 
-      ActionMeta before = (ActionMeta) action.cloneDeep();
+      byte[] beforeSnapshot = workflowGraph.captureUndoSnapshot();
 
       IAction jei = action.getAction();
 
@@ -304,13 +304,7 @@ public class HopGuiWorkflowActionDelegate {
           // If so, we need to verify that the name is not already used in the 
workflow.
           //
           workflowMeta.renameActionIfNameCollides(action);
-
-          ActionMeta after = action.clone();
-          hopGui.undoDelegate.addUndoChange(
-              workflowMeta,
-              new ActionMeta[] {before},
-              new ActionMeta[] {after},
-              new int[] {workflowMeta.indexOfAction(action)});
+          workflowGraph.commitDialogUndo(beforeSnapshot);
         }
         workflowGraph.updateGui();
       } else {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
index 833f1af46c..3298882611 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowClipboardDelegate.java
@@ -158,8 +158,7 @@ public class HopGuiWorkflowClipboardDelegate {
       // This is the offset:
       Point offset = new Point(location.x - min.x, location.y - min.y);
 
-      // Undo/redo object positions...
-      int[] position = new int[actions.length];
+      workflowGraph.markUndoPoint();
 
       for (int i = 0; i < actions.length; i++) {
         Point p = actions[i].getLocation();
@@ -170,7 +169,6 @@ public class HopGuiWorkflowClipboardDelegate {
         actionsOldNames.add(name);
         actions[i].setName(workflowMeta.getAlternativeActionName(name));
         workflowMeta.addAction(actions[i]);
-        position[i] = workflowMeta.indexOfAction(actions[i]);
         actions[i].setSelected(true);
       }
 
@@ -188,20 +186,7 @@ public class HopGuiWorkflowClipboardDelegate {
         note.setSelected(true);
       }
 
-      // Save undo information too...
-      hopGui.undoDelegate.addUndoNew(workflowMeta, actions, position, false);
-
-      int[] hopPos = new int[hops.length];
-      for (int i = 0; i < hops.length; i++) {
-        hopPos[i] = workflowMeta.indexOfWorkflowHop(hops[i]);
-      }
-      hopGui.undoDelegate.addUndoNew(workflowMeta, hops, hopPos, true);
-
-      int[] notePos = new int[notes.length];
-      for (int i = 0; i < notes.length; i++) {
-        notePos[i] = workflowMeta.indexOfNote(notes[i]);
-      }
-      hopGui.undoDelegate.addUndoNew(workflowMeta, notes, notePos, true);
+      // Undo was recorded once before adding the pasted objects.
 
     } catch (HopException e) {
       // See if this was different (non-XML) content
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowUndoDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowUndoDelegate.java
index 7409b17134..f7872677fa 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowUndoDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowUndoDelegate.java
@@ -17,337 +17,45 @@
 
 package org.apache.hop.ui.hopgui.file.workflow.delegates;
 
-import org.apache.hop.core.NotePadMeta;
-import org.apache.hop.core.gui.Point;
-import org.apache.hop.core.undo.ChangeAction;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
 import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
-import org.apache.hop.workflow.WorkflowHopMeta;
 import org.apache.hop.workflow.WorkflowMeta;
-import org.apache.hop.workflow.action.ActionMeta;
 
+/**
+ * Workflow undo/redo is implemented with gzip XML snapshots on {@link 
HopGuiWorkflowGraph}. This
+ * class remains as a hook for any leftover callers.
+ */
 public class HopGuiWorkflowUndoDelegate {
 
   private HopGuiWorkflowGraph workflowGraph;
   private HopGui hopGui;
 
-  /**
-   * @param hopGui
-   */
   public HopGuiWorkflowUndoDelegate(HopGui hopGui, HopGuiWorkflowGraph 
workflowGraph) {
     this.hopGui = hopGui;
     this.workflowGraph = workflowGraph;
   }
 
   public void undoWorkflowAction(IHopFileTypeHandler handler, WorkflowMeta 
workflowMeta) {
-    ChangeAction changeAction = workflowMeta.previousUndo();
-    if (changeAction == null) {
-      return;
-    }
-    undoWorkflowAction(handler, workflowMeta, changeAction);
-    handler.updateGui();
-  }
-
-  public void undoWorkflowAction(
-      IHopFileTypeHandler handler, WorkflowMeta workflowMeta, ChangeAction 
changeAction) {
-    switch (changeAction.getType()) {
-        // We created a new transform : undo this...
-      case NewAction:
-        // Delete the transform at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeAction(idx);
-        }
-        break;
-
-        // We created a new note : undo this...
-      case NewNote:
-        // Delete the note at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeNote(idx);
-        }
-        break;
-
-        // We created a new hop : undo this...
-      case NewWorkflowHop:
-        // Delete the hop at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeWorkflowHop(idx);
-        }
-        break;
-
-        //
-        // DELETE
-        //
-
-        // We delete a transform : undo this...
-      case DeleteAction:
-        // un-Delete the transform at correct location: re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          ActionMeta action = (ActionMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.addAction(idx, action);
-        }
-        break;
-
-        // We delete new note : undo this...
-      case DeleteNote:
-        // re-insert the note at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          NotePadMeta ni = (NotePadMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.addNote(idx, ni);
-        }
-        break;
-
-        // We deleted a hop : undo this...
-      case DeleteWorkflowHop:
-        // re-insert the hop at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          WorkflowHopMeta hopMeta = (WorkflowHopMeta) 
changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          // Build a new hop:
-          ActionMeta from = 
workflowMeta.findAction(hopMeta.getFromAction().getName());
-          ActionMeta to = 
workflowMeta.findAction(hopMeta.getToAction().getName());
-          WorkflowHopMeta newHopMeta = new WorkflowHopMeta(from, to);
-          newHopMeta.setEvaluation(hopMeta.isEvaluation());
-          newHopMeta.setUnconditional(hopMeta.isUnconditional());
-          workflowMeta.addWorkflowHop(idx, newHopMeta);
-        }
-        break;
-
-        //
-        // CHANGE
-        //
-
-        // We changed a transform : undo this...
-      case ChangeAction:
-        // Delete the current transform, insert previous version.
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          ActionMeta prev = ((ActionMeta) 
changeAction.getPrevious()[i]).clone();
-          int idx = changeAction.getCurrentIndex()[i];
-
-          workflowMeta.getAction(idx).replaceMeta(prev);
-        }
-        break;
-
-        // We changed a note : undo this...
-      case ChangeNote:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeNote(idx);
-          NotePadMeta prev = (NotePadMeta) changeAction.getPrevious()[i];
-          workflowMeta.addNote(idx, (NotePadMeta) prev.clone());
-        }
-        break;
-
-        // We changed a hop : undo this...
-      case ChangeWorkflowHop:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          WorkflowHopMeta prev = (WorkflowHopMeta) 
changeAction.getPrevious()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-
-          workflowMeta.removeWorkflowHop(idx);
-          workflowMeta.addWorkflowHop(idx, (WorkflowHopMeta) prev.clone());
-        }
-        break;
-
-        //
-        // POSITION
-        //
-
-        // The position of a transform has changed: undo this...
-      case PositionAction:
-        // Find the location of the transform:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          ActionMeta action = 
workflowMeta.getAction(changeAction.getCurrentIndex()[i]);
-          action.setLocation(changeAction.getPreviousLocation()[i]);
-        }
-        break;
-
-        // The position of a note has changed: undo this...
-      case PositionNote:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          int idx = changeAction.getCurrentIndex()[i];
-          NotePadMeta npi = workflowMeta.getNote(idx);
-          Point prev = changeAction.getPreviousLocation()[i];
-          npi.setLocation(prev);
-        }
-        break;
-      default:
-        break;
-    }
-
-    // OK, now check if we need to do this again...
-    if (workflowMeta.viewNextUndo() != null && 
workflowMeta.viewNextUndo().getNextAlso()) {
-      undoWorkflowAction(handler, workflowMeta);
-    }
+    workflowGraph.undo();
   }
 
   public void redoWorkflowAction(IHopFileTypeHandler handler, WorkflowMeta 
workflowMeta) {
-    ChangeAction changeAction = workflowMeta.nextUndo();
-    if (changeAction == null) {
-      return;
-    }
-    redoWorkflowAction(handler, workflowMeta, changeAction);
-    handler.updateGui();
-  }
-
-  public void redoWorkflowAction(
-      IHopFileTypeHandler handler, WorkflowMeta workflowMeta, ChangeAction 
changeAction) {
-    switch (changeAction.getType()) {
-      case NewAction:
-        // re-delete the transform at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          ActionMeta entryCopy = (ActionMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.addAction(idx, entryCopy);
-        }
-        break;
-
-      case NewNote:
-        // re-insert the note at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          NotePadMeta ni = (NotePadMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.addNote(idx, ni);
-        }
-        break;
-
-      case NewWorkflowHop:
-        // re-insert the hop at correct location:
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          WorkflowHopMeta hopMeta = (WorkflowHopMeta) 
changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.addWorkflowHop(idx, hopMeta);
-        }
-        break;
-
-        //
-        // DELETE
-        //
-      case DeleteAction:
-        // re-remove the transform at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeAction(idx);
-        }
-        break;
-
-      case DeleteNote:
-        // re-remove the note at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeNote(idx);
-        }
-        break;
-
-      case DeleteWorkflowHop:
-        // re-remove the hop at correct location:
-        for (int i = changeAction.getCurrent().length - 1; i >= 0; i--) {
-          int idx = changeAction.getCurrentIndex()[i];
-          workflowMeta.removeWorkflowHop(idx);
-        }
-        break;
-
-        //
-        // CHANGE
-        //
-
-        // We changed a transform : undo this...
-      case ChangeTransform:
-        // Delete the current transform, insert previous version.
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          ActionMeta clonedEntry = ((ActionMeta) 
changeAction.getCurrent()[i]).clone();
-          
workflowMeta.getAction(changeAction.getCurrentIndex()[i]).replaceMeta(clonedEntry);
-        }
-        break;
-
-        // We changed a note : undo this...
-      case ChangeNote:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          NotePadMeta ni = (NotePadMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-
-          workflowMeta.removeNote(idx);
-          workflowMeta.addNote(idx, ni.clone());
-        }
-        break;
-
-        // We changed a hop : undo this...
-      case ChangeWorkflowHop:
-        // Delete & re-insert
-        for (int i = 0; i < changeAction.getCurrent().length; i++) {
-          WorkflowHopMeta hi = (WorkflowHopMeta) changeAction.getCurrent()[i];
-          int idx = changeAction.getCurrentIndex()[i];
-
-          workflowMeta.removeWorkflowHop(idx);
-          workflowMeta.addWorkflowHop(idx, (WorkflowHopMeta) hi.clone());
-        }
-        break;
-
-        //
-        // CHANGE POSITION
-        //
-      case PositionAction:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          // Find & change the location of the transform:
-          ActionMeta action = 
workflowMeta.getAction(changeAction.getCurrentIndex()[i]);
-          action.setLocation(changeAction.getCurrentLocation()[i]);
-        }
-        break;
-      case PositionNote:
-        for (int i = 0; i < changeAction.getCurrentIndex().length; i++) {
-          int idx = changeAction.getCurrentIndex()[i];
-          NotePadMeta npi = workflowMeta.getNote(idx);
-          Point curr = changeAction.getCurrentLocation()[i];
-          npi.setLocation(curr);
-        }
-        break;
-      default:
-        break;
-    }
-
-    // OK, now check if we need to do this again...
-    if (workflowMeta.viewNextUndo() != null && 
workflowMeta.viewNextUndo().getNextAlso()) {
-      redoWorkflowAction(handler, workflowMeta);
-    }
+    workflowGraph.redo();
   }
 
-  /**
-   * Gets workflowGraph
-   *
-   * @return value of workflowGraph
-   */
   public HopGuiWorkflowGraph getWorkflowGraph() {
     return workflowGraph;
   }
 
-  /**
-   * @param workflowGraph The workflowGraph to set
-   */
   public void setWorkflowGraph(HopGuiWorkflowGraph workflowGraph) {
     this.workflowGraph = workflowGraph;
   }
 
-  /**
-   * Gets hopGui
-   *
-   * @return value of hopGui
-   */
   public HopGui getHopGui() {
     return hopGui;
   }
 
-  /**
-   * @param hopGui The hopGui to set
-   */
   public void setHopGui(HopGui hopGui) {
     this.hopGui = hopGui;
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/config/ExplorerPerspectiveConfigPlugin.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/config/ExplorerPerspectiveConfigPlugin.java
index 13c2ab7cf2..9c6218dcf4 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/config/ExplorerPerspectiveConfigPlugin.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/config/ExplorerPerspectiveConfigPlugin.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.ui.hopgui.perspective.explorer.config;
 
+import org.apache.hop.core.Const;
 import org.apache.hop.core.config.plugin.ConfigPlugin;
 import org.apache.hop.core.config.plugin.IConfigOptions;
 import org.apache.hop.core.exception.HopException;
@@ -52,6 +53,7 @@ public class ExplorerPerspectiveConfigPlugin
       "10200-file-explorer-visible-by-default";
   private static final String WIDGET_ID_OPEN_HELP_FILES = 
"10300-open-help-files";
   private static final String WIDGET_ID_ACTIVE_FILE_SELECTION = 
"10400-active-file-selection";
+  private static final String WIDGET_ID_MAX_UNDO = "10500-max-undo";
 
   @GuiWidgetElement(
       id = WIDGET_ID_LAZY_LOADING_DEPTH,
@@ -110,6 +112,18 @@ public class ExplorerPerspectiveConfigPlugin
       description = "Automatically select the active tab file in the file 
explorer tree")
   private Boolean activeFileSelection = true;
 
+  @GuiWidgetElement(
+      id = WIDGET_ID_MAX_UNDO,
+      parentId = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID,
+      type = GuiElementType.TEXT,
+      label = "i18n::ExplorerPerspectiveConfig.MaxUndo.Label",
+      toolTip = "i18n::ExplorerPerspectiveConfig.MaxUndo.Tooltip")
+  @CommandLine.Option(
+      names = {"-mu", "--max-undo"},
+      description =
+          "The maximum number of undo operations kept for pipelines, workflows 
and tables")
+  private String maxUndo;
+
   /**
    * Gets instance
    *
@@ -125,6 +139,7 @@ public class ExplorerPerspectiveConfigPlugin
     instance.fileExplorerVisibleByDefault = visibleByDefault != null ? 
visibleByDefault : true;
     instance.openingHelpFiles = config.isOpeningHelpFiles();
     instance.activeFileSelection = config.getActiveFileSelection();
+    instance.maxUndo = 
Integer.toString(org.apache.hop.ui.core.PropsUi.getInstance().getMaxUndo());
 
     return instance;
   }
@@ -176,6 +191,12 @@ public class ExplorerPerspectiveConfigPlugin
         changed = true;
       }
 
+      if (maxUndo != null) {
+        persistMaxUndo(maxUndo);
+        log.logBasic("Maximum undo operations is set to '" + maxUndo + "'");
+        changed = true;
+      }
+
       // Save to file if anything changed
       //
       if (changed) {
@@ -230,6 +251,14 @@ public class ExplorerPerspectiveConfigPlugin
           ExplorerPerspectiveConfigSingleton.getConfig()
               .setActiveFileSelection(activeFileSelection);
           break;
+        case WIDGET_ID_MAX_UNDO:
+          if (control instanceof TextVar textVar) {
+            maxUndo = textVar.getText();
+          } else {
+            maxUndo = ((org.eclipse.swt.widgets.Text) control).getText();
+          }
+          persistMaxUndo(maxUndo);
+          break;
         default:
           break;
       }
@@ -282,4 +311,20 @@ public class ExplorerPerspectiveConfigPlugin
   public void setActiveFileSelection(Boolean activeFileSelection) {
     this.activeFileSelection = activeFileSelection;
   }
+
+  public String getMaxUndo() {
+    return maxUndo;
+  }
+
+  public void setMaxUndo(String maxUndo) {
+    this.maxUndo = maxUndo;
+  }
+
+  private static void persistMaxUndo(String maxUndoText) {
+    int value = Const.toInt(maxUndoText, Const.MAX_UNDO);
+    if (value < 1) {
+      value = 1;
+    }
+    org.apache.hop.ui.core.PropsUi.getInstance().setMaxUndo(value);
+  }
 }
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 0aac9a2983..a9fa08b436 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -133,6 +133,10 @@ HopGui.Menu.Tools.DatabaseClearCache=Clear databases cache
 HopGui.Menu.Tools.EditConfigVariables=Edit config variables...
 HopGui.Menu.Undo.Available=Undo \: {0}
 HopGui.Menu.Undo.NotAvailable=Undo \: not available
+HopGui.Undo.Error.Apply.Message=Unable to apply the undo/redo snapshot
+HopGui.Undo.Error.Apply.Title=Undo error
+HopGui.Undo.Error.Record.Message=Unable to record undo snapshot
+HopGui.Undo.Error.Record.Title=Undo error
 HopGui.Message.Warning.NotShowWarning=Please, don''t show this warning anymore 
(also available in the options dialog).
 
HopGui.PipelineExecutionConfigurationDialog.Help=pipeline/run-preview-debug-pipeline.html
 HopGui.PipelineGraph.GridTab.Name=Metrics
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/config/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/config/messages/messages_en_US.properties
index b19a2b9820..8147d0c3a2 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/config/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/explorer/config/messages/messages_en_US.properties
@@ -28,3 +28,6 @@ ExplorerPerspectiveConfig.OpenHelpFiles.Tooltip=When checked, 
help links will op
 ExplorerPerspectiveConfig.ActiveFileSelection.Label=Select active file in tree 
automatically
 ExplorerPerspectiveConfig.ActiveFileSelection.Tooltip=Automatically select the 
active tab file in the file explorer tree on the left hand side when it is 
shown in a tab.
 
+ExplorerPerspectiveConfig.MaxUndo.Label=Maximum undo operations
+ExplorerPerspectiveConfig.MaxUndo.Tooltip=The maximum number of undo/redo 
snapshots kept for pipelines, workflows and table editors. Default is 100.
+

Reply via email to